diff --git a/package.json b/package.json index be8640487..60733898a 100644 --- a/package.json +++ b/package.json @@ -521,6 +521,12 @@ "category": "R", "command": "r.goToNextChunk" }, + { + "command": "r.rmarkdown.setKnitDirectory", + "title": "R: Set Knit directory", + "icon": "$(zap)", + "category": "R Markdown" + }, { "command": "r.rmarkdown.showPreviewToSide", "title": "Open Preview to the Side", @@ -867,7 +873,7 @@ "command": "r.runSource" }, { - "when": "editorLangId == rmd", + "when": "editorLangId == rmd && editorFocus", "command": "r.knitRmd", "group": "navigation" } @@ -1006,8 +1012,18 @@ { "command": "r.rmarkdown.showPreviewToSide", "alt": "r.rmarkdown.showPreview", - "when": "editorLangId == rmd", + "when": "editorLangId == rmd && editorFocus", "group": "navigation" + }, + { + "submenu": "r.knitCommands", + "when": "editorLangId == rmd && editorFocus", + "group": "@1" + }, + { + "command": "r.rmarkdown.setKnitDirectory", + "when": "editorLangId == rmd && editorFocus", + "group": "@1" } ], "editor/context": [ @@ -1149,8 +1165,29 @@ "when": "resourceLangId == rmd", "group": "navigation" } + ], + "r.knitCommands": [ + { + "command": "r.knitRmd" + }, + { + "command": "r.knitRmdToPdf" + }, + { + "command": "r.knitRmdToHtml" + }, + { + "command": "r.knitRmdToAll" + } ] }, + "submenus": [ + { + "id": "r.knitCommands", + "label": "R: Knit", + "icon": "$(zap)" + } + ], "configuration": { "type": "object", "title": "R", @@ -1262,7 +1299,37 @@ "r.rmarkdown.preview.autoRefresh": { "type": "boolean", "default": true, - "description": "Enable automatic refresh of R Markdown preview on file update." + "markdownDescription": "Enable automatic refresh of R Markdown preview on file update." + }, + "r.rmarkdown.knit.useBackgroundProcess": { + "type": "boolean", + "default": true, + "markdownDescription": "Should knitting occur in a background process (*smart knitting*), or should it be done in the current R terminal (*manual knitting*)? \n\n*Smart knitting* includes additional features, such as custom knit function detection, R Markdown site detection, progress bars, and the setting knit directory." + }, + "r.rmarkdown.knit.focusOutputChannel": { + "type": "boolean", + "default": true, + "markdownDescription": "Should the R Markdown output channel be focused when knitting?\n\nRequires `#r.rmarkdown.knit.useBackgroundProcess#` to be set to `true`." + }, + "r.rmarkdown.knit.openOutputFile": { + "type": "boolean", + "default": false, + "markdownDescription": "Should the output file be opened automatically when using knit?\n\nRequires `#r.rmarkdown.knit.useBackgroundProcess#` to be set to `true`." + }, + "r.rmarkdown.knit.defaults.knitWorkingDirectory": { + "type": "string", + "default": "document directory", + "enum": [ + "document directory", + "workspace root" + ], + "enumDescriptions": [ + "Use the document's directory as the knit directory", + "Use the workspace root as the knit directory" + ], + "markdownDescription": "What working directory should R Markdown chunks be evaluated in? Default knit behaviour is to use the document's directory as root.\n\nRequires `#r.rmarkdown.knit.useBackgroundProcess#` to be set to `true`.", + "additionalItems": false, + "additionalProperties": false }, "r.helpPanel.enableSyntaxHighlighting": { "type": "boolean", @@ -1592,6 +1659,7 @@ "@types/express": "^4.17.12", "@types/fs-extra": "^9.0.11", "@types/highlight.js": "^10.1.0", + "@types/js-yaml": "^4.0.2", "@types/mocha": "^8.2.2", "@types/node": "^14.17.3", "@types/node-fetch": "^2.5.10", @@ -1623,13 +1691,14 @@ "highlight.js": "^10.7.2", "jquery": "^3.6.0", "jquery.json-viewer": "^1.4.0", + "js-yaml": "^4.1.0", "node-fetch": "^2.6.1", "popper.js": "^1.16.1", "showdown": "^1.9.1", "tree-kill": "^1.2.2", + "vscode-languageclient": "^7.0.0", "vsls": "^1.0.3015", "winreg": "^1.2.4", - "ws": "^7.4.6", - "vscode-languageclient": "^7.0.0" + "ws": "^7.4.6" } } diff --git a/src/extension.ts b/src/extension.ts index e68b6e246..4656413ee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,7 +22,6 @@ import * as rShare from './liveShare'; import * as httpgdViewer from './plotViewer'; import * as languageService from './languageService'; -import { RMarkdownPreviewManager } from './rmarkdown/preview'; // global objects used in other files export const homeExtDir = (): string => util.getDir(path.join(os.homedir(), '.vscode-R')); @@ -32,7 +31,8 @@ export let globalRHelp: rHelp.RHelp | undefined = undefined; export let extensionContext: vscode.ExtensionContext; export let enableSessionWatcher: boolean = undefined; export let globalHttpgdManager: httpgdViewer.HttpgdManager | undefined = undefined; -export let rMarkdownPreview: RMarkdownPreviewManager | undefined = undefined; +export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined; +export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { @@ -51,6 +51,8 @@ export async function activate(context: vscode.ExtensionContext): Promise('sessionWatcher'); + rmdPreviewManager = new rmarkdown.RMarkdownPreviewManager(); + rmdKnitManager = new rmarkdown.RMarkdownKnitManager(); // register commands specified in package.json @@ -75,10 +77,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { void rTerminal.runSource(true); }, // rmd related - 'r.knitRmd': () => { void rTerminal.knitRmd(false, undefined); }, - 'r.knitRmdToPdf': () => { void rTerminal.knitRmd(false, 'pdf_document'); }, - 'r.knitRmdToHtml': () => { void rTerminal.knitRmd(false, 'html_document'); }, - 'r.knitRmdToAll': () => { void rTerminal.knitRmd(false, 'all'); }, + 'r.knitRmd': () => { void rmdKnitManager.knitRmd(false, undefined); }, + 'r.knitRmdToPdf': () => { void rmdKnitManager.knitRmd(false, 'pdf_document'); }, + 'r.knitRmdToHtml': () => { void rmdKnitManager.knitRmd(false, 'html_document'); }, + 'r.knitRmdToAll': () => { void rmdKnitManager.knitRmd(false, 'all'); }, 'r.selectCurrentChunk': rmarkdown.selectCurrentChunk, 'r.runCurrentChunk': rmarkdown.runCurrentChunk, 'r.runPreviousChunk': rmarkdown.runPreviousChunk, @@ -91,14 +93,15 @@ export async function activate(context: vscode.ExtensionContext): Promise rMarkdownPreview.previewRmd(vscode.ViewColumn.Beside), - 'r.rmarkdown.showPreview': (uri: vscode.Uri) => rMarkdownPreview.previewRmd(vscode.ViewColumn.Active, uri), - 'r.rmarkdown.preview.refresh': () => rMarkdownPreview.updatePreview(), - 'r.rmarkdown.preview.openExternal': () => void rMarkdownPreview.openExternalBrowser(), - 'r.rmarkdown.preview.showSource': () => rMarkdownPreview.showSource(), - 'r.rmarkdown.preview.toggleStyle': () => rMarkdownPreview.toggleTheme(), - 'r.rmarkdown.preview.enableAutoRefresh': () => rMarkdownPreview.enableAutoRefresh(), - 'r.rmarkdown.preview.disableAutoRefresh': () => rMarkdownPreview.disableAutoRefresh(), + 'r.rmarkdown.setKnitDirectory': () => rmdKnitManager.setKnitDir(), + 'r.rmarkdown.showPreviewToSide': () => rmdPreviewManager.previewRmd(vscode.ViewColumn.Beside), + 'r.rmarkdown.showPreview': (uri: vscode.Uri) => rmdPreviewManager.previewRmd(vscode.ViewColumn.Active, uri), + 'r.rmarkdown.preview.refresh': () => rmdPreviewManager.updatePreview(), + 'r.rmarkdown.preview.openExternal': () => void rmdPreviewManager.openExternalBrowser(), + 'r.rmarkdown.preview.showSource': () => rmdPreviewManager.showSource(), + 'r.rmarkdown.preview.toggleStyle': () => rmdPreviewManager.toggleTheme(), + 'r.rmarkdown.preview.enableAutoRefresh': () => rmdPreviewManager.enableAutoRefresh(), + 'r.rmarkdown.preview.disableAutoRefresh': () => rmdPreviewManager.disableAutoRefresh(), // editor independent commands 'r.createGitignore': rGitignore.createGitignore, @@ -166,10 +169,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { } } -export async function knitRmd(echo: boolean, outputFormat?: string): Promise { - const wad: vscode.TextDocument = vscode.window.activeTextEditor.document; - const isSaved = await util.saveDocument(wad); - if (isSaved) { - let rPath = util.ToRStringLiteral(wad.fileName, '"'); - let encodingParam = util.config().get('source.encoding'); - encodingParam = `encoding = "${encodingParam}"`; - rPath = [rPath, encodingParam].join(', '); - if (echo) { - rPath = [rPath, 'echo = TRUE'].join(', '); - } - if (outputFormat === undefined) { - void runTextInTerm(`rmarkdown::render(${rPath})`); - } else { - void runTextInTerm(`rmarkdown::render(${rPath}, "${outputFormat}")`); - } - } -} - export async function runSelection(): Promise { await runSelectionInTerm(true); } diff --git a/src/rmarkdown/index.ts b/src/rmarkdown/index.ts index 2210e3a66..bd981fd86 100644 --- a/src/rmarkdown/index.ts +++ b/src/rmarkdown/index.ts @@ -1,12 +1,12 @@ -import { - CancellationToken, CodeLens, CodeLensProvider, - CompletionItem, CompletionItemProvider, - Event, EventEmitter, Position, Range, TextDocument, TextEditorDecorationType, window, Selection, commands -} from 'vscode'; +import * as vscode from 'vscode'; import { runChunksInTerm } from '../rTerminal'; import { config } from '../util'; -function isRDocument(document: TextDocument) { +// reexports +export { knitDir, RMarkdownKnitManager } from './knit'; +export { RMarkdownPreviewManager } from './preview'; + +function isRDocument(document: vscode.TextDocument) { return (document.languageId === 'r'); } @@ -42,23 +42,23 @@ function getChunkEval(chunkOptions: string) { return (!chunkOptions.match(/eval\s*=\s*(F|FALSE)/g)); } -export class RMarkdownCodeLensProvider implements CodeLensProvider { - private codeLenses: CodeLens[] = []; - private _onDidChangeCodeLenses: EventEmitter = new EventEmitter(); - private readonly decoration: TextEditorDecorationType; - public readonly onDidChangeCodeLenses: Event = this._onDidChangeCodeLenses.event; +export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { + private codeLenses: vscode.CodeLens[] = []; + private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter(); + private readonly decoration: vscode.TextEditorDecorationType; + public readonly onDidChangeCodeLenses: vscode.Event = this._onDidChangeCodeLenses.event; constructor() { - this.decoration = window.createTextEditorDecorationType({ + this.decoration = vscode.window.createTextEditorDecorationType({ isWholeLine: true, backgroundColor: config().get('rmarkdown.chunkBackgroundColor'), }); } - public provideCodeLenses(document: TextDocument, token: CancellationToken): CodeLens[] | Thenable { + public provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.CodeLens[] | Thenable { this.codeLenses = []; const chunks = getChunks(document); - const chunkRanges: Range[] = []; + const chunkRanges: vscode.Range[] = []; const rmdCodeLensCommands: string[] = config().get('rmarkdown.codeLensCommands'); // Iterate through all code chunks for getting chunk information for both CodeLens and chunk background color (set by `editor.setDecorations`) @@ -74,61 +74,61 @@ export class RMarkdownCodeLensProvider implements CodeLensProvider { break; } this.codeLenses.push( - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Chunk', tooltip: 'Run current chunk', command: 'r.runCurrentChunk', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Above', tooltip: 'Run all chunks above', command: 'r.runAboveChunks', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Current & Below', tooltip: 'Run current and all chunks below', command: 'r.runCurrentAndBelowChunks', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Below', tooltip: 'Run all chunks below', command: 'r.runBelowChunks', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Previous', tooltip: 'Run previous chunk', command: 'r.runPreviousChunk', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run Next', tooltip: 'Run next chunk', command: 'r.runNextChunk', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Run All', tooltip: 'Run all chunks', command: 'r.runAllChunks', arguments: [chunks] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Go Previous', tooltip: 'Go to previous chunk', command: 'r.goToPreviousChunk', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Go Next', tooltip: 'Go to next chunk', command: 'r.goToNextChunk', arguments: [chunks, line] }), - new CodeLens(chunkRange, { + new vscode.CodeLens(chunkRange, { title: 'Select Chunk', tooltip: 'Select current chunk', command: 'r.selectCurrentChunk', @@ -138,7 +138,7 @@ export class RMarkdownCodeLensProvider implements CodeLensProvider { } } - for (const editor of window.visibleTextEditors) { + for (const editor of vscode.window.visibleTextEditors) { if (editor.document.uri.toString() === document.uri.toString()) { editor.setDecorations(this.decoration, chunkRanges); } @@ -154,7 +154,7 @@ export class RMarkdownCodeLensProvider implements CodeLensProvider { return sorted; }); } - public resolveCodeLens(codeLens: CodeLens): CodeLens { + public resolveCodeLens(codeLens: vscode.CodeLens): vscode.CodeLens { return codeLens; } } @@ -166,12 +166,12 @@ interface RMarkdownChunk { language: string; options: string; eval: boolean; - chunkRange: Range; - codeRange: Range; + chunkRange: vscode.Range; + codeRange: vscode.Range; } // Scan document and return chunk info (e.g. ID, chunk range) from all chunks -function getChunks(document: TextDocument): RMarkdownChunk[] { +function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { const lines = document.getText().split(/\r?\n/); const chunks: RMarkdownChunk[] = []; @@ -197,13 +197,13 @@ function getChunks(document: TextDocument): RMarkdownChunk[] { if (isChunkEndLine(lines[line], isRDoc)) { chunkEndLine = line; - const chunkRange = new Range( - new Position(chunkStartLine, 0), - new Position(line, lines[line].length) + const chunkRange = new vscode.Range( + new vscode.Position(chunkStartLine, 0), + new vscode.Position(line, lines[line].length) ); - const codeRange = new Range( - new Position(chunkStartLine + 1, 0), - new Position(line - 1, lines[line - 1].length) + const codeRange = new vscode.Range( + new vscode.Position(chunkStartLine + 1, 0), + new vscode.Position(line - 1, lines[line - 1].length) ); chunks.push({ @@ -226,13 +226,13 @@ function getChunks(document: TextDocument): RMarkdownChunk[] { } function getCurrentChunk(chunks: RMarkdownChunk[], line: number): RMarkdownChunk { - const lines = window.activeTextEditor.document.getText().split(/\r?\n/); + const lines = vscode.window.activeTextEditor.document.getText().split(/\r?\n/); let chunkStartLineAtOrAbove = line; // `- 1` to cover edge case when cursor is at 'chunk end line' let chunkEndLineAbove = line - 1; - const isRDoc = isRDocument(window.activeTextEditor.document); + const isRDoc = isRDocument(vscode.window.activeTextEditor.document); while (chunkStartLineAtOrAbove >= 0 && !isChunkStartLine(lines[chunkStartLineAtOrAbove], isRDoc)) { chunkStartLineAtOrAbove--; @@ -291,10 +291,10 @@ function getNextChunk(chunks: RMarkdownChunk[], line: number): RMarkdownChunk { // Helpers function _getChunks() { - return getChunks(window.activeTextEditor.document); + return getChunks(vscode.window.activeTextEditor.document); } function _getStartLine() { - return window.activeTextEditor.selection.start.line; + return vscode.window.activeTextEditor.selection.start.line; } export async function runCurrentChunk(chunks: RMarkdownChunk[] = _getChunks(), @@ -331,7 +331,7 @@ export async function runAboveChunks(chunks: RMarkdownChunk[] = _getChunks(), const firstChunkId = 1; const previousChunkId = previousChunk.id; - const codeRanges: Range[] = []; + const codeRanges: vscode.Range[] = []; if (previousChunk !== currentChunk) { for (let i = firstChunkId; i <= previousChunkId; i++) { @@ -352,7 +352,7 @@ export async function runBelowChunks(chunks: RMarkdownChunk[] = _getChunks(), const nextChunkId = nextChunk.id; const lastChunkId = chunks.length; - const codeRanges: Range[] = []; + const codeRanges: vscode.Range[] = []; if (nextChunk !== currentChunk) { for (let i = nextChunkId; i <= lastChunkId; i++) { const chunk = chunks.find(e => e.id === i); @@ -370,7 +370,7 @@ export async function runCurrentAndBelowChunks(chunks: RMarkdownChunk[] = _getCh const currentChunkId = currentChunk.id; const lastChunkId = chunks.length; - const codeRanges: Range[] = []; + const codeRanges: vscode.Range[] = []; for (let i = currentChunkId; i <= lastChunkId; i++) { const chunk = chunks.find(e => e.id === i); @@ -384,7 +384,7 @@ export async function runAllChunks(chunks: RMarkdownChunk[] = _getChunks()): Pro const firstChunkId = 1; const lastChunkId = chunks.length; - const codeRanges: Range[] = []; + const codeRanges: vscode.Range[] = []; for (let i = firstChunkId; i <= lastChunkId; i++) { const chunk = chunks.find(e => e.id === i); @@ -398,8 +398,8 @@ export async function runAllChunks(chunks: RMarkdownChunk[] = _getChunks()): Pro async function goToChunk(chunk: RMarkdownChunk) { // Move cursor 1 line below 'chunk start line' const line = chunk.startLine + 1; - window.activeTextEditor.selection = new Selection(line, 0, line, 0); - await commands.executeCommand('revealLine', { lineNumber: line, at: 'center'}); + vscode.window.activeTextEditor.selection = new vscode.Selection(line, 0, line, 0); + await vscode.commands.executeCommand('revealLine', { lineNumber: line, at: 'center'}); } export function goToPreviousChunk(chunks: RMarkdownChunk[] = _getChunks(), @@ -416,17 +416,17 @@ export function goToNextChunk(chunks: RMarkdownChunk[] = _getChunks(), export function selectCurrentChunk(chunks: RMarkdownChunk[] = _getChunks(), line: number = _getStartLine()): void { - const editor = window.activeTextEditor; + const editor = vscode.window.activeTextEditor; const currentChunk = getCurrentChunk__CursorWithinChunk(chunks, line); const lines = editor.document.getText().split(/\r?\n/); - editor.selection = new Selection( + editor.selection = new vscode.Selection( currentChunk.startLine, 0, currentChunk.endLine, lines[currentChunk.endLine].length ); } -export class RMarkdownCompletionItemProvider implements CompletionItemProvider { +export class RMarkdownCompletionItemProvider implements vscode.CompletionItemProvider { // obtained from R code // paste0("[", paste0(paste0("'", names(knitr:: opts_chunk$merge(NULL)), "'"), collapse = ", "), "]") @@ -440,17 +440,17 @@ export class RMarkdownCompletionItemProvider implements CompletionItemProvider { 'external', 'sanitize', 'interval', 'aniopts', 'warning', 'error', 'message', 'render', 'ref.label', 'child', 'engine', 'split', 'include', 'purl']; - public readonly chunkOptionCompletionItems: CompletionItem[]; + public readonly chunkOptionCompletionItems: vscode.CompletionItem[]; constructor() { this.chunkOptionCompletionItems = this.chunkOptions.map((x: string) => { - const item = new CompletionItem(`${x}`); + const item = new vscode.CompletionItem(`${x}`); item.insertText = `${x}=`; return item; }); } - public provideCompletionItems(document: TextDocument, position: Position): CompletionItem[] { + public provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] { const line = document.lineAt(position).text; if (isChunkStartLine(line, false) && getChunkLanguage(line) === 'r') { return this.chunkOptionCompletionItems; diff --git a/src/rmarkdown/knit.ts b/src/rmarkdown/knit.ts new file mode 100644 index 000000000..39f906546 --- /dev/null +++ b/src/rmarkdown/knit.ts @@ -0,0 +1,253 @@ +import * as util from '../util'; +import * as vscode from 'vscode'; +import * as fs from 'fs-extra'; +import path = require('path'); +import yaml = require('js-yaml'); + +import { RMarkdownManager, KnitWorkingDirectory } from './manager'; +import { runTextInTerm } from '../rTerminal'; +import { rmdPreviewManager } from '../extension'; + +export let knitDir: KnitWorkingDirectory = util.config().get('rmarkdown.knit.defaults.knitWorkingDirectory') ?? undefined; + +interface IKnitQuickPickItem { + label: string, + description: string, + detail: string, + value: KnitWorkingDirectory +} + +interface IYamlFrontmatter { + title?: string, + author?: string, + knit?: string, + site?: string, + [key: string]: unknown +} + +export class RMarkdownKnitManager extends RMarkdownManager { + private async renderDocument(rPath: string, docPath: string, docName: string, yamlParams: IYamlFrontmatter, outputFormat?: string) { + const openOutfile: boolean = util.config().get('rmarkdown.knit.openOutputFile') ?? false; + const knitWorkingDir = this.getKnitDir(knitDir, docPath); + const knitWorkingDirText = knitWorkingDir ? `'${knitWorkingDir}'` : `NULL`; + const knitCommand = await this.getKnitCommand(yamlParams, rPath, outputFormat); + this.rPath = await util.getRpath(true); + + const lim = '---vsc---'; + const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'gms'); + const cmd = ( + `${this.rPath} --silent --slave --no-save --no-restore ` + + `-e "knitr::opts_knit[['set']](root.dir = ${knitWorkingDirText})" ` + + `-e "cat('${lim}', ` + + `${knitCommand}, ` + + `'${lim}',` + + `sep='')"` + ); + + const callback = (dat: string) => { + const outputUrl = re.exec(dat)?.[0]?.replace(re, '$1'); + if (outputUrl) { + if (openOutfile) { + const outFile = vscode.Uri.file(outputUrl); + if (fs.existsSync(outFile.fsPath)) { + void vscode.commands.executeCommand('vscode.open', outFile); + } else { + void vscode.window.showWarningMessage(`Could not find the output file at path: "${outFile.fsPath}"`); + } + } + return true; + } else { + return false; + } + }; + + if (util.config().get('rmarkdown.knit.focusOutputChannel')) { + this.rMarkdownOutput.show(true); + } + + return await this.knitWithProgress( + { + fileName: docName, + filePath: rPath, + cmd: cmd, + rCmd: knitCommand, + rOutputFormat: outputFormat, + callback: callback + } + ); + + } + + private getYamlFrontmatter(docPath: string): IYamlFrontmatter { + const parseData = fs.readFileSync(docPath, 'utf8'); + const yamlDat = /(?<=(---)).*(?=(---))/gs.exec( + parseData + ); + + let paramObj = {}; + if (yamlDat) { + try { + paramObj = yaml.load( + yamlDat[0] + ); + } catch (e) { + console.error(`Could not parse YAML frontmatter for "${docPath}". Error: ${String(e)}`); + } + } + + return paramObj; + } + + private async getKnitCommand(yamlParams: IYamlFrontmatter, docPath: string, outputFormat: string): Promise { + let knitCommand: string; + + if (!yamlParams?.['site']) { + yamlParams['site'] = await this.findSiteParam(); + } + + // precedence: + // knit > site > none + if (yamlParams?.['knit']) { + const knitParam = yamlParams['knit']; + knitCommand = outputFormat ? + `${knitParam}(${docPath}, output_format = '${outputFormat}')`: + `${knitParam}(${docPath})`; + } else if (!this.isREADME(docPath) && yamlParams?.['site']) { + knitCommand = outputFormat ? + `rmarkdown::render_site(${docPath}, output_format = '${outputFormat}')` : + `rmarkdown::render_site(${docPath})`; + } else { + knitCommand = outputFormat ? + `rmarkdown::render(${docPath}, output_format = '${outputFormat}')` : + `rmarkdown::render(${docPath})`; + } + + return knitCommand.replace(/['"]/g, '\\"'); + } + + // check if the workspace of the document is a R Markdown site. + // the definition of what constitutes an R Markdown site differs + // depending on the type of R Markdown site (i.e., "simple" vs. blogdown sites) + private async findSiteParam(): Promise { + const rootFolder = vscode.workspace.workspaceFolders[0].uri.fsPath; + const wad = vscode.window.activeTextEditor.document.uri.fsPath; + const indexFile = (await vscode.workspace.findFiles(new vscode.RelativePattern(rootFolder, 'index.{Rmd,rmd, md}'), null, 1))?.[0]; + const siteRoot = path.join(path.dirname(wad), '_site.yml'); + + // 'Simple' R Markdown websites require all docs to be in the root folder + if (fs.existsSync(siteRoot)) { + return 'rmarkdown::render_site'; + // Other generators may allow for docs in subdirs + } else if (indexFile) { + const indexData = this.getYamlFrontmatter(indexFile.fsPath); + if (indexData?.['site']) { + return indexData['site']; + } + } + + return undefined; + } + + // readme files should not be knitted via render_site + private isREADME(docPath: string) { + return !!path.basename(docPath).includes('README'); + } + + // alters the working directory for evaluating chunks + public setKnitDir(): void { + const currentDocumentWorkspacePath: string = vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor?.document?.uri)?.uri?.fsPath; + const currentDocumentFolderPath: string = path.dirname(vscode.window?.activeTextEditor.document?.uri?.fsPath); + const items: IKnitQuickPickItem[] = []; + + if (currentDocumentWorkspacePath) { + items.push( + { + label: (knitDir === KnitWorkingDirectory.workspaceRoot ? '$(check)' : '') + KnitWorkingDirectory.workspaceRoot, + value: KnitWorkingDirectory.workspaceRoot, + detail: 'Use the workspace root as the knit working directory', + description: currentDocumentWorkspacePath ?? currentDocumentFolderPath ?? 'No available workspace' + } + ); + } + + if (currentDocumentFolderPath && currentDocumentFolderPath !== '.') { + items.push( + { + label: (knitDir === KnitWorkingDirectory.documentDirectory ? '$(check)' : '') + KnitWorkingDirectory.documentDirectory, + value: KnitWorkingDirectory.documentDirectory, + detail: 'Use the document\'s directory as the knit working directory', + description: currentDocumentFolderPath ?? 'No folder available' + + } + ); + } + + if (items.length > 0) { + void vscode.window.showQuickPick( + items, + { + title: 'Set knit working directory', + canPickMany: false + } + ).then(async choice => { + if (choice?.value && knitDir !== choice.value) { + knitDir = choice.value; + await rmdPreviewManager.updatePreview(); + } + }); + } else { + void vscode.window.showInformationMessage('Cannot set knit directory for untitled documents.'); + } + + } + + public async knitRmd(echo: boolean, outputFormat?: string): Promise { + const wad: vscode.TextDocument = vscode.window.activeTextEditor.document; + + // handle untitled rmd + if (vscode.window.activeTextEditor.document.isUntitled) { + void vscode.window.showWarningMessage('Cannot knit an untitled file. Please save the document.'); + await vscode.commands.executeCommand('workbench.action.files.save').then(() => { + if (!vscode.window.activeTextEditor.document.isUntitled) { + void this.knitRmd(echo, outputFormat); + } + }); + return; + } + + const isSaved = await util.saveDocument(wad); + if (isSaved) { + let rPath = util.ToRStringLiteral(wad.fileName, '"'); + let encodingParam = util.config().get('source.encoding'); + encodingParam = `encoding = "${encodingParam}"`; + rPath = [rPath, encodingParam].join(', '); + if (echo) { + rPath = [rPath, 'echo = TRUE'].join(', '); + } + + // allow users to opt out of background process + if (util.config().get('rmarkdown.knit.useBackgroundProcess')) { + const busyPath = wad.uri.fsPath + outputFormat; + if (this.busyUriStore.has(busyPath)) { + return; + } else { + this.busyUriStore.add(busyPath); + await this.renderDocument( + rPath, + wad.uri.fsPath, + path.basename(wad.uri.fsPath), + this.getYamlFrontmatter(wad.uri.fsPath), + outputFormat + ); + this.busyUriStore.delete(busyPath); + } + } else { + if (outputFormat === undefined) { + void runTextInTerm(`rmarkdown::render(${rPath})`); + } else { + void runTextInTerm(`rmarkdown::render(${rPath}, "${outputFormat}")`); + } + } + } + } +} diff --git a/src/rmarkdown/manager.ts b/src/rmarkdown/manager.ts new file mode 100644 index 000000000..7de093958 --- /dev/null +++ b/src/rmarkdown/manager.ts @@ -0,0 +1,148 @@ +import * as util from '../util'; +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import path = require('path'); + +export enum KnitWorkingDirectory { + documentDirectory = 'document directory', + workspaceRoot = 'workspace root', +} + +interface IKnitArgs { + filePath: string; + fileName: string; + cmd: string; + rCmd?: string; + rOutputFormat?: string; + callback: (...args: unknown[]) => boolean; + onRejection?: (...args: unknown[]) => unknown; +} + +export interface IKnitRejection { + cp: cp.ChildProcessWithoutNullStreams; + wasCancelled: boolean; +} + +const rMarkdownOutput: vscode.OutputChannel = vscode.window.createOutputChannel('R Markdown'); + +export abstract class RMarkdownManager { + protected rPath: string = undefined; + protected rMarkdownOutput: vscode.OutputChannel = rMarkdownOutput; + // uri that are in the process of knitting + // so that we can't spam the knit/preview button + protected busyUriStore: Set = new Set(); + + protected getKnitDir(knitDir: string, docPath?: string): string { + const currentDocumentWorkspace = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(docPath) ?? vscode.window.activeTextEditor?.document?.uri)?.uri?.fsPath ?? undefined; + switch (knitDir) { + // the directory containing the R Markdown document + case KnitWorkingDirectory.documentDirectory: { + return path.dirname(docPath).replace(/\\/g, '/').replace(/['"]/g, '\\"'); + } + // the root of the current workspace + case KnitWorkingDirectory.workspaceRoot: { + return currentDocumentWorkspace.replace(/\\/g, '/').replace(/['"]/g, '\\"'); + } + // the working directory of the attached terminal, NYI + // case 'current directory': { + // return NULL + // } + default: return undefined; + } + } + + protected async knitDocument(args: IKnitArgs, token?: vscode.CancellationToken, progress?: vscode.Progress): Promise { + // vscode.Progress auto-increments progress, so we use this + // variable to set progress to a specific number + let currentProgress = 0; + return await new Promise( + (resolve, reject) => { + const cmd = args.cmd; + const fileName = args.fileName; + let childProcess: cp.ChildProcessWithoutNullStreams; + + try { + childProcess = cp.exec(cmd); + progress.report({ + increment: 0, + message: '0%' + }); + } catch (e: unknown) { + console.warn(`[VSC-R] error: ${e as string}`); + reject({ cp: childProcess, wasCancelled: false }); + } + + this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process started`); + + if (args.rCmd) { + this.rMarkdownOutput.appendLine(`==> ${args.rCmd}`); + } + + childProcess.stdout.on('data', + (data: Buffer) => { + const dat = data.toString('utf8'); + this.rMarkdownOutput.appendLine(dat); + const percentRegex = /[0-9]+(?=%)/g; + const percentRegOutput = dat.match(percentRegex); + if (percentRegOutput) { + for (const item of percentRegOutput) { + const perc = Number(item); + progress.report( + { + increment: perc - currentProgress, + message: `${perc}%` + } + ); + currentProgress = perc; + } + } + if (token?.isCancellationRequested) { + resolve(childProcess); + } else { + if (args.callback(dat, childProcess)) { + resolve(childProcess); + } + } + } + ); + + childProcess.stderr.on('data', (data: Buffer) => { + const dat = data.toString('utf8'); + this.rMarkdownOutput.appendLine(dat); + }); + + childProcess.on('exit', (code, signal) => { + this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process exited ` + + (signal ? `from signal '${signal}'` : `with exit code ${code}`)); + if (code !== 0) { + reject({ cp: childProcess, wasCancelled: false }); + } + }); + + token?.onCancellationRequested(() => { + reject({ cp: childProcess, wasCancelled: true }); + }); + } + ); + } + + protected async knitWithProgress(args: IKnitArgs): Promise { + let childProcess: cp.ChildProcessWithoutNullStreams = undefined; + await util.doWithProgress(async (token: vscode.CancellationToken, progress: vscode.Progress) => { + childProcess = await this.knitDocument(args, token, progress) as cp.ChildProcessWithoutNullStreams; + }, + vscode.ProgressLocation.Notification, + `Knitting ${args.fileName} ${args.rOutputFormat ? 'to ' + args.rOutputFormat : ''} `, + true + ).catch((rejection: IKnitRejection) => { + if (!rejection.wasCancelled) { + void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); + this.rMarkdownOutput.show(true); + } + // this can occur when a successfuly knitted document is later altered (while still being previewed) and subsequently fails to knit + args?.onRejection ? args.onRejection(args.filePath, rejection) : + rejection?.cp.kill('SIGKILL'); + }); + return childProcess; + } +} diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts index f721667bf..571c4f1f9 100644 --- a/src/rmarkdown/preview.ts +++ b/src/rmarkdown/preview.ts @@ -8,8 +8,10 @@ import path = require('path'); import crypto = require('crypto'); -import { config, doWithProgress, getRpath, readContent, setContext, escapeHtml, UriIcon } from '../util'; +import { config, readContent, setContext, escapeHtml, UriIcon, saveDocument, getRpath } from '../util'; import { extensionContext, tmpDir } from '../extension'; +import { knitDir } from './knit'; +import { IKnitRejection, RMarkdownManager } from './manager'; class RMarkdownPreview extends vscode.Disposable { title: string; @@ -164,24 +166,19 @@ class RMarkdownPreviewStore extends vscode.Disposable { } } -export class RMarkdownPreviewManager { - private rPath: string; - private rMarkdownOutput: vscode.OutputChannel = vscode.window.createOutputChannel('R Markdown'); - +export class RMarkdownPreviewManager extends RMarkdownManager { // the currently selected RMarkdown preview - private activePreview: { filePath: string, preview: RMarkdownPreview } = { filePath: null, preview: null }; + private activePreview: { filePath: string, preview: RMarkdownPreview, title: string } = { filePath: null, preview: null, title: null }; // store of all open RMarkdown previews private previewStore: RMarkdownPreviewStore = new RMarkdownPreviewStore; - // uri that are in the process of knitting - // so that we can't spam the preview button - private busyUriStore: Set = new Set(); private useCodeTheme = true; - public async init(): Promise { - this.rPath = await getRpath(true); + constructor() { + super(); extensionContext.subscriptions.push(this.previewStore); } + public async previewRmd(viewer: vscode.ViewColumn, uri?: vscode.Uri): Promise { const filePath = uri ? uri.fsPath : vscode.window.activeTextEditor.document.uri.fsPath; const fileName = path.basename(filePath); @@ -198,16 +195,21 @@ export class RMarkdownPreviewManager { return; } - // don't knit if the current uri is already being knit - if (this.busyUriStore.has(filePath)) { - return; - } else if (this.previewStore.has(filePath)) { - this.previewStore.get(filePath)?.panel.reveal(); - } else { - this.busyUriStore.add(filePath); - await vscode.commands.executeCommand('workbench.action.files.save'); - await this.knitWithProgress(filePath, fileName, viewer, currentViewColumn); - this.busyUriStore.delete(filePath); + const isSaved = uri ? + true : + await saveDocument(vscode.window.activeTextEditor.document); + + if (isSaved) { + // don't knit if the current uri is already being knit + if (this.busyUriStore.has(filePath)) { + return; + } else if (this.previewStore.has(filePath)) { + this.previewStore.get(filePath)?.panel.reveal(); + } else { + this.busyUriStore.add(filePath); + await this.previewDocument(filePath, fileName, viewer, currentViewColumn); + this.busyUriStore.delete(filePath); + } } } @@ -263,117 +265,81 @@ export class RMarkdownPreviewManager { public async updatePreview(preview?: RMarkdownPreview): Promise { const toUpdate = preview ?? this.activePreview?.preview; const previewUri = this.previewStore?.getFilePath(toUpdate); - toUpdate.cp?.kill('SIGKILL'); + toUpdate?.cp?.kill('SIGKILL'); - const childProcess: cp.ChildProcessWithoutNullStreams | void = await this.knitWithProgress(previewUri, toUpdate.title).catch(() => { - void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); - this.rMarkdownOutput.show(true); - this.previewStore.delete(previewUri); - }); + if (toUpdate) { + const childProcess: cp.ChildProcessWithoutNullStreams | void = await this.previewDocument(previewUri, toUpdate.title).catch(() => { + void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); + this.rMarkdownOutput.show(true); + this.previewStore.delete(previewUri); + }); + + if (childProcess) { + toUpdate.cp = childProcess; + } - if (childProcess) { - toUpdate.cp = childProcess; + this.refreshPanel(toUpdate); } - this.refreshPanel(toUpdate); } - private async knitWithProgress(filePath: string, fileName: string, viewer?: vscode.ViewColumn, currentViewColumn?: vscode.ViewColumn) { - let childProcess:cp.ChildProcessWithoutNullStreams = undefined; - await doWithProgress(async (token: vscode.CancellationToken) => { - childProcess = await this.knitDocument(filePath, fileName, token, viewer, currentViewColumn); - }, - vscode.ProgressLocation.Notification, - `Knitting ${fileName}...`, - true - ).catch((rejection: { - cp: cp.ChildProcessWithoutNullStreams, - wasCancelled?: boolean - }) => { - if (!rejection.wasCancelled) { - void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); - this.rMarkdownOutput.show(true); + private async previewDocument(filePath: string, fileName?: string, viewer?: vscode.ViewColumn, currentViewColumn?: vscode.ViewColumn) { + const knitWorkingDir = this.getKnitDir(knitDir, filePath); + const knitWorkingDirText = knitWorkingDir ? `'${knitWorkingDir}'` : `NULL`; + this.rPath = await getRpath(true); + + const lim = '---vsc---'; + const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); + const outputFile = path.join(tmpDir(), crypto.createHash('sha256').update(filePath).digest('hex') + '.html'); + const cmd = ( + `${this.rPath} --silent --slave --no-save --no-restore ` + + `-e "knitr::opts_knit[['set']](root.dir = ${knitWorkingDirText})" ` + + `-e "cat('${lim}', rmarkdown::render(` + + `'${filePath.replace(/\\/g, '/')}',` + + `output_format = rmarkdown::html_document(),` + + `output_file = '${outputFile.replace(/\\/g, '/')}',` + + `intermediates_dir = '${tmpDir().replace(/\\/g, '/')}'), '${lim}',` + + `sep='')"` + ); + + const callback = (dat: string, childProcess: cp.ChildProcessWithoutNullStreams) => { + const outputUrl = re.exec(dat)?.[0]?.replace(re, '$1'); + if (outputUrl) { + if (viewer !== undefined) { + const autoRefresh = config().get('rmarkdown.preview.autoRefresh'); + void this.openPreview( + vscode.Uri.parse(outputUrl), + filePath, + fileName, + childProcess, + viewer, + currentViewColumn, + autoRefresh + ); + } + return true; } - // this can occur when a successfuly knitted document is later altered (while still being previewed) - // and subsequently fails to knit + return false; + }; + + const onRejected = (filePath: string, rejection: IKnitRejection) => { if (this.previewStore.has(filePath)) { this.previewStore.delete(filePath); } else { rejection.cp.kill('SIGKILL'); } - }); - return childProcess; - } + }; - private async knitDocument(filePath: string, fileName: string, token?: vscode.CancellationToken, viewer?: vscode.ViewColumn, currentViewColumn?: vscode.ViewColumn) { - return await new Promise((resolve, reject) => { - const lim = '---vsc---'; - const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); - const outputFile = path.join(tmpDir(), crypto.createHash('sha256').update(filePath).digest('hex') + '.html'); - const cmd = ( - `${this.rPath} --silent --slave --no-save --no-restore -e ` + - `"cat('${lim}', rmarkdown::render(` + - `'${filePath.replace(/\\/g, '/')}',` + - `output_format = rmarkdown::html_document(),` + - `output_file = '${outputFile.replace(/\\/g, '/')}',` + - `intermediates_dir = '${tmpDir().replace(/\\/g, '/')}'), '${lim}',` + - `sep='')"` - ); - - let childProcess: cp.ChildProcessWithoutNullStreams; - try { - childProcess = cp.exec(cmd); - } catch (e: unknown) { - console.warn(`[VSC-R] error: ${e as string}`); - reject({ cp: childProcess, wasCancelled: false }); + return await this.knitWithProgress( + { + fileName: fileName, + filePath: filePath, + cmd: cmd, + rOutputFormat: 'html preview', + callback: callback, + onRejection: onRejected } - - this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process started`); - - childProcess.stdout.on('data', - (data: Buffer) => { - const dat = data.toString('utf8'); - this.rMarkdownOutput.appendLine(dat); - if (token?.isCancellationRequested) { - resolve(childProcess); - } else { - const outputUrl = re.exec(dat)?.[0]?.replace(re, '$1'); - if (outputUrl) { - if (viewer !== undefined) { - const autoRefresh = config().get('rmarkdown.preview.autoRefresh'); - void this.openPreview( - vscode.Uri.parse(outputUrl), - filePath, - fileName, - childProcess, - viewer, - currentViewColumn, - autoRefresh - ); - } - resolve(childProcess); - } - } - } - ); - - childProcess.stderr.on('data', (data: Buffer) => { - const dat = data.toString('utf8'); - this.rMarkdownOutput.appendLine(dat); - }); - - childProcess.on('exit', (code, signal) => { - this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process exited ` + - (signal ? `from signal '${signal}'` : `with exit code ${code}`)); - if (code !== 0) { - reject({ cp: childProcess, wasCancelled: false }); - } - }); - - token?.onCancellationRequested(() => { - reject({ cp: childProcess, wasCancelled: true }); - }); - }); + ); } private openPreview(outputUri: vscode.Uri, filePath: string, title: string, cp: cp.ChildProcessWithoutNullStreams, viewer: vscode.ViewColumn, resourceViewColumn: vscode.ViewColumn, autoRefresh: boolean): void { @@ -414,7 +380,7 @@ export class RMarkdownPreviewManager { // state change panel.onDidDispose(() => { // clear values - this.activePreview = this.activePreview?.preview === preview ? { filePath: null, preview: null } : this.activePreview; + this.activePreview = this.activePreview?.preview === preview ? { filePath: null, preview: null, title: null } : this.activePreview; void setContext('r.rmarkdown.preview.active', false); this.previewStore.delete(filePath); }); @@ -424,6 +390,7 @@ export class RMarkdownPreviewManager { if (webviewPanel.active) { this.activePreview.preview = preview; this.activePreview.filePath = filePath; + this.activePreview.title = title; void setContext('r.rmarkdown.preview.autoRefresh', preview.autoRefresh); } }); diff --git a/src/util.ts b/src/util.ts index c392a8953..cffeeb3a8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -231,7 +231,7 @@ export async function executeAsTask(name: string, command: string, args?: string // executes a callback and shows a 'busy' progress bar during the execution // synchronous callbacks are converted to async to properly render the progress bar // default location is in the help pages tree view -export async function doWithProgress(cb: (token?: vscode.CancellationToken) => T | Promise, location: string | vscode.ProgressLocation = 'rHelpPages', title?: string, cancellable?: boolean): Promise { +export async function doWithProgress(cb: (token?: vscode.CancellationToken, progress?: vscode.Progress) => T | Promise, location: string | vscode.ProgressLocation = 'rHelpPages', title?: string, cancellable?: boolean): Promise { const location2 = (typeof location === 'string' ? { viewId: location } : location); const options: vscode.ProgressOptions = { location: location2, @@ -239,9 +239,9 @@ export async function doWithProgress(cb: (token?: vscode.CancellationToken) = title: title }; let ret: T; - await vscode.window.withProgress(options, async (_progress, token) => { + await vscode.window.withProgress(options, async (progress, token) => { const retPromise = new Promise((resolve) => setTimeout(() => { - const ret = cb(token); + const ret = cb(token, progress); resolve(ret); })); ret = await retPromise; @@ -249,7 +249,6 @@ export async function doWithProgress(cb: (token?: vscode.CancellationToken) = return ret; } - // get the URL of a CRAN website // argument path is optional and should be relative to the cran root // currently the CRAN root url is hardcoded, this could be replaced by reading diff --git a/yarn.lock b/yarn.lock index 687d04424..85c3c1b0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -194,6 +194,11 @@ dependencies: highlight.js "*" +"@types/js-yaml@^4.0.2": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.3.tgz#9f33cd6fbf0d5ec575dc8c8fc69c7fec1b4eb200" + integrity sha512-5t9BhoORasuF5uCPr+d5/hdB++zRFUTMIZOzbNkr+jZh3yQht4HYbRDyj9fY8n2TZT30iW9huzav73x4NikqWg== + "@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.6": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" @@ -1736,6 +1741,13 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + jsdoc-type-pratt-parser@1.0.0-alpha.23: version "1.0.0-alpha.23" resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-1.0.0-alpha.23.tgz#01c232d92b99b7e7ef52235ab8c9115137426639"