diff --git a/CHANGELOG.md b/CHANGELOG.md index b856bfb..d5b0e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 기반으로 하며, 이 프로젝트는 [유의적 버전 관리](https://semver.org/lang/ko/)를 따릅니다. +## [0.69.0] - 2026-08-18 + +### Added +- **연구노트 PDF 내보내기 (`Labnote: Export Research Notebook PDF`)**: 실험 폴더와 포함할 파일(README + 연결된 워크플로)을 QuickPick으로 선택하면, 표지(제목/작성자/생성일/최종 수정일)와 본문을 담은 인쇄용 HTML을 만들어 OS 기본 브라우저로 엶. 브라우저 인쇄(Ctrl/Cmd+P → PDF로 저장)로 PDF를 생성. 표/이미지/코드 블록이 페이지 중간에서 끊기지 않도록 인쇄용 CSS 적용 +- **표 CSV 내보내기 (`Labnote: Export Tables as CSV`)**: 동일한 파일 선택 UX를 재사용해 선택한 파일들의 마크다운 표를 표마다 하나씩 CSV로 저장 + ## [0.68.2] - 2026-07-10 ### Added diff --git a/README.ko.md b/README.ko.md index ef8abfb..6e0a11b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -216,6 +216,14 @@ VS Code에서 실험 노트를 마크다운으로 작성할 수 있도록 돕는 - 줌 컨트롤 (+, -, Reset) - 키보드 단축키: Esc(닫기), +/-(줌), 0(리셋) +### 내보내기 (PDF / CSV) + +- **`Labnote: Export Research Notebook PDF`**: 인쇄용 보고서 HTML을 만들어 OS 기본 브라우저로 열어줍니다. 브라우저의 인쇄 기능(`Ctrl/Cmd+P` → *PDF로 저장*)으로 PDF를 저장하면 됩니다. 확장 프로그램 자체는 PDF를 직접 생성하지 않으므로 별도로 설치할 것이 없습니다. + - **범위 선택**: 실험 폴더를 고른 뒤(가능하면 현재 열린 편집기에서 자동 감지, 아니면 워크스페이스 전체를 대상으로 QuickPick), `README.labnote.md`와 그에 연결된 워크플로 중 포함할 파일을 다중 선택 QuickPick으로 고릅니다. Related Workflows 체크리스트에 체크된 항목은 기본으로 선택되어 있습니다. + - **표지 정보**: 보고서는 첫 번째로 선택한 파일의 제목/작성자/생성일/최종 수정일을 표지로 보여줍니다(front matter 값을 그대로 사용, 재계산하지 않음). 추가로 포함된 파일마다 각자의 소제목과 생성일/최종 수정일이 함께 표시되어, 여러 파일을 함께 내보내도 파일별 날짜가 정확합니다. + - **서식**: VS Code 내장 마크다운 렌더러로 본문을 렌더링해 보기 좋게 표시하고(사용할 수 없으면 최소한의 내장 렌더러로 대체), 이미지는 워크스페이스 밖에서도 제대로 보이도록 절대 경로로 변환되며, 인쇄용 CSS로 표/이미지/코드 블록이 페이지 중간에서 끊기지 않도록 합니다. +- **`Labnote: Export Tables as CSV`**: 동일한 파일 선택 QuickPick을 재사용한 뒤, 선택한 파일들에서 마크다운 표(`| ... |`)를 모두 찾아 표마다 하나씩 `.csv` 파일로 지정한 폴더에 저장합니다. + ### 실험 노트 폴더 구조 - **새 실험 생성**: `Labnote: Create New Labnote Folder` 명령으로 실험 제목과 작성자 입력 @@ -272,6 +280,8 @@ VS Code에서 실험 노트를 마크다운으로 작성할 수 있도록 돕는 | `Labnote: Open with Section Editor` | 현재 마크다운 파일을 Section Editor로 열기 | | `Labnote: Open as Markdown Editor` | 현재 파일을 텍스트 에디터로 열기 | | `Labnote: Open Preview` | 현재 파일의 마크다운 미리보기 열기 | +| `Labnote: Export Research Notebook PDF` | 인쇄용 HTML 보고서를 브라우저로 열기 (인쇄 → PDF로 저장) | +| `Labnote: Export Tables as CSV` | 선택한 파일에서 마크다운 표를 CSV로 추출 | ## 문제 해결 diff --git a/README.md b/README.md index 990a0f0..ccf89b3 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,14 @@ When a regular `.md` file is opened in the text editor: - Zoom controls (+, -, Reset). - Keyboard shortcuts: Esc (close), +/- (zoom), 0 (reset). +### Export (PDF / CSV) + +- **`Labnote: Export Research Notebook PDF`**: builds a printable report and opens it in your OS default browser so you can use the browser's own Print dialog (`Ctrl/Cmd+P` → *Save as PDF*) to produce the PDF. The extension does not generate the PDF itself — no bundled PDF engine, so there is nothing extra to install. + - **Scope selection**: pick the experiment folder (auto-detected from the active editor when possible, otherwise a QuickPick over the workspace), then choose which files to include — `README.labnote.md` plus any of its linked workflows — via a multi-select QuickPick. Files already checked in the Related Workflows checklist are pre-selected. + - **Cover info**: the report opens with a cover showing the first selected file's title, author, created date, and last-updated date, taken verbatim from the front matter (no recomputation). Every additional file included gets its own heading with its own created/last-updated dates, so multi-file reports stay accurate per file. + - **Formatting**: Markdown is rendered via VS Code's built-in Markdown renderer for proper styling (falls back to a minimal built-in renderer if unavailable), images are resolved to absolute paths so they display correctly outside the workspace, and print CSS keeps tables/images/code blocks from splitting across a page break. +- **`Labnote: Export Tables as CSV`**: reuses the same file-selection QuickPick, then extracts every Markdown table (`| ... |`) from the selected files and writes one `.csv` per table into a folder you choose. + ### Experiment folder structure - **Create a new experiment**: `Labnote: Create New Labnote Folder` prompts for title and author. @@ -271,6 +279,8 @@ When a regular `.md` file is opened in the text editor: | `Labnote: Open with Section Editor` | Open the current Markdown file in the Section Editor | | `Labnote: Open as Markdown Editor` | Open the current file in the text editor | | `Labnote: Open Preview` | Open the Markdown preview for the current file | +| `Labnote: Export Research Notebook PDF` | Open a printable HTML report in your browser (Print → Save as PDF) | +| `Labnote: Export Tables as CSV` | Extract Markdown tables from selected files as CSV | ## Troubleshooting diff --git a/eslint.config.mjs b/eslint.config.mjs index c8bc64a..4104631 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,5 +34,16 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], }, + }, + { + // This file's exported function returns source that Playwright injects + // into a page via page.evaluate(), so it runs in a browser, not Node. + files: ['tools/demo-recorder/lib/highlight.js'], + languageOptions: { + globals: { + window: 'readonly', + document: 'readonly', + }, + }, } ); diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index dfa0e62..728fce5 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -137,5 +137,19 @@ "Insert to Editor only works in the Section Editor. In text mode, use the search icon in the Lab Samples view (Labnote: Search Sample) to insert at the cursor.": "Insert to Editor는 Section Editor에서만 동작합니다. 텍스트 모드에서는 Lab Samples 뷰의 돋보기 아이콘(Labnote: Search Sample 명령)을 사용해 커서 위치에 삽입하세요.", "Open a .labnote.md file with the Section Editor, then click Insert to Editor on a sample.": ".labnote.md 파일을 Section Editor로 연 뒤, 샘플의 Insert to Editor를 클릭하세요.", - "No {0} products found. Add a catalog at resources/labsamples/{0}_*.json.": "검색할 {0} 제품이 없습니다. resources/labsamples/{0}_*.json 카탈로그 파일을 추가하세요." + "No {0} products found. Add a catalog at resources/labsamples/{0}_*.json.": "검색할 {0} 제품이 없습니다. resources/labsamples/{0}_*.json 카탈로그 파일을 추가하세요.", + + "Select an experiment folder to export": "내보낼 실험 폴더를 선택하세요", + "No lab note experiment folder was found in this workspace.": "이 워크스페이스에서 실험 폴더를 찾을 수 없습니다.", + "Select the lab note files to include": "포함할 랩노트 파일을 선택하세요", + "Select at least one file to export.": "내보낼 파일을 하나 이상 선택하세요.", + "No exportable lab note files were found.": "내보낼 수 있는 랩노트 파일이 없습니다.", + "Author": "작성자", + "Created": "생성일", + "Last updated": "최종 수정일", + "Report opened in your browser. Use Print (Ctrl/Cmd+P) to save it as a PDF.": "보고서를 브라우저에서 열었습니다. 인쇄(Ctrl/Cmd+P)로 PDF로 저장하세요.", + "Could not open the report automatically. Open {0} in a browser and print it as a PDF.": "보고서를 자동으로 열지 못했습니다. {0} 파일을 브라우저에서 열어 PDF로 인쇄하세요.", + "No Markdown tables were found in the selected files.": "선택한 파일에서 Markdown 표를 찾을 수 없습니다.", + "Choose Folder": "폴더 선택", + "Exported {0} table(s) as CSV to {1}.": "표 {0}개를 CSV로 {1}에 내보냈습니다." } diff --git a/package-lock.json b/package-lock.json index 880f51d..6b6d9c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labnotev", - "version": "0.60.7", + "version": "0.68.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labnotev", - "version": "0.60.7", + "version": "0.68.2", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 1ab64ed..a617152 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "labnotev", "displayName": "Labnote Assistant", "description": "Markdown-based lab notebook with sample tracking, workflow checklists, and a structured Section Editor for biology and bioinformatics experiments.", - "version": "0.68.2", + "version": "0.69.0", "license": "MIT", "publisher": "korea-biofoundry", "icon": "resources/icons/labnotev-128.png", @@ -240,6 +240,14 @@ "command": "labnotev.openPreview", "title": "%labnotev.cmd.openPreview.title%", "icon": "$(open-preview)" + }, + { + "command": "labnotev.exportPdf", + "title": "%labnotev.cmd.exportPdf.title%" + }, + { + "command": "labnotev.exportTablesCsv", + "title": "%labnotev.cmd.exportTablesCsv.title%" } ], "menus": { diff --git a/package.nls.json b/package.nls.json index 507cc8b..8d9fc1d 100644 --- a/package.nls.json +++ b/package.nls.json @@ -30,5 +30,7 @@ "labnotev.cmd.moveSampleToLocal.title": "Labnote: Move to Local", "labnotev.cmd.openWithSectionEditor.title": "Labnote: Open with Section Editor", "labnotev.cmd.openAsTextEditor.title": "Labnote: Open as Markdown Editor", - "labnotev.cmd.openPreview.title": "Labnote: Open Preview" + "labnotev.cmd.openPreview.title": "Labnote: Open Preview", + "labnotev.cmd.exportPdf.title": "Labnote: Export Research Notebook PDF", + "labnotev.cmd.exportTablesCsv.title": "Labnote: Export Tables as CSV" } diff --git a/package.nls.ko.json b/package.nls.ko.json index 07d12cf..e7bdccc 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -30,5 +30,7 @@ "labnotev.cmd.moveSampleToLocal.title": "Labnote: 로컬로 이동", "labnotev.cmd.openWithSectionEditor.title": "Labnote: Section Editor로 열기", "labnotev.cmd.openAsTextEditor.title": "Labnote: Markdown 편집기로 열기", - "labnotev.cmd.openPreview.title": "Labnote: 미리보기 열기" + "labnotev.cmd.openPreview.title": "Labnote: 미리보기 열기", + "labnotev.cmd.exportPdf.title": "Labnote: 연구노트 PDF 내보내기", + "labnotev.cmd.exportTablesCsv.title": "Labnote: 표 CSV로 내보내기" } diff --git a/src/__tests__/exportCommands.test.ts b/src/__tests__/exportCommands.test.ts new file mode 100644 index 0000000..48d4234 --- /dev/null +++ b/src/__tests__/exportCommands.test.ts @@ -0,0 +1,239 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { mockVscode } from './setup'; +import { openFileInOsDefaultApp } from '../lib/openInOs'; + +vi.mock('../lib/openInOs', () => ({ + openFileInOsDefaultApp: vi.fn(), +})); + +function makeContext() { + return { subscriptions: [], extensionUri: { fsPath: '/test' }, extensionPath: '/test' } as any; +} + +/** Creates a temp experiment folder mirroring `labnote/{###_Name}/README.labnote.md` layout. */ +function makeExperiment() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'labnotev-exp-')); + const dir = path.join(root, 'labnote', '001_Test'); + fs.mkdirSync(dir, { recursive: true }); + + const readmePath = path.join(dir, 'README.labnote.md'); + fs.writeFileSync( + readmePath, + `--- +title: pmid_39169056 +author: Sebin Heo +experiment_type: labnote +sample_tracking: no +created_date: 2026-08-04 +last_updated_date: 2026-08-10 +--- + +## Objective + +Readme body. + +## Related Workflows + +[x] [001 WL050 Transcriptome Analysis](./001_WL050_Transcriptome_Analysis.labnote.md) +`, + 'utf8' + ); + + const workflowPath = path.join(dir, '001_WL050_Transcriptome_Analysis.labnote.md'); + fs.writeFileSync( + workflowPath, + `--- +title: WL050 Transcriptome Analysis +experimenter: Sebin Heo +created_date: 2026-08-04 +last_updated_date: 2026-08-18 +end_date: '' +--- + +## [WL050 Transcriptome Analysis] + +| contrast | total | up | down | +|---|---|---|---| +| 1h_vs_0h | 1436 | 530 | 906 | +`, + 'utf8' + ); + + return { root, dir, readmePath, workflowPath }; +} + +describe('registerExportCommands', () => { + let exp: ReturnType; + + beforeEach(() => { + exp = makeExperiment(); + mockVscode.window.activeTextEditor = { + document: { uri: { fsPath: exp.readmePath } }, + } as any; + }); + + afterEach(() => { + fs.rmSync(exp.root, { recursive: true, force: true }); + mockVscode.window.activeTextEditor = undefined; + }); + + it('registers labnotev.exportPdf and labnotev.exportTablesCsv', async () => { + const { registerExportCommands } = await import('../commands/exportCommands'); + registerExportCommands(makeContext()); + + const registered = mockVscode.commands.registerCommand.mock.calls.map((c) => c[0]); + expect(registered).toContain('labnotev.exportPdf'); + expect(registered).toContain('labnotev.exportTablesCsv'); + }); + + describe('labnotev.exportPdf', () => { + async function getHandler() { + const { registerExportCommands } = await import('../commands/exportCommands'); + registerExportCommands(makeContext()); + const call = mockVscode.commands.registerCommand.mock.calls.find((c) => c[0] === 'labnotev.exportPdf'); + return call?.[1] as () => Promise; + } + + it('renders selected sources, writes an HTML report, and opens it', async () => { + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => Promise.resolve(items)); + mockVscode.commands.executeCommand.mockImplementation((...args: unknown[]) => { + const [cmd, arg] = args as [string, string]; + if (cmd === 'markdown.api.render') return Promise.resolve(`

${arg.trim()}

`); + return Promise.resolve(undefined); + }); + vi.mocked(openFileInOsDefaultApp).mockResolvedValue(true); + + const handler = await getHandler(); + await handler(); + + expect(openFileInOsDefaultApp).toHaveBeenCalledTimes(1); + const htmlPath = vi.mocked(openFileInOsDefaultApp).mock.calls[0][0]; + expect(htmlPath.startsWith(os.tmpdir())).toBe(true); + expect(htmlPath.endsWith('.html')).toBe(true); + + const html = fs.readFileSync(htmlPath, 'utf8'); + expect(html).toContain('pmid_39169056'); + expect(html).toContain('2026-08-04'); + expect(html).toContain('2026-08-10'); + expect(html).toContain('WL050 Transcriptome Analysis'); + expect(html).toContain('2026-08-18'); + + expect(mockVscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('Print') + ); + }); + + it('falls back to the minimal renderer when markdown.api.render is unavailable', async () => { + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => Promise.resolve(items)); + mockVscode.commands.executeCommand.mockImplementation((...args: unknown[]) => { + const [cmd] = args as [string]; + if (cmd === 'markdown.api.render') return Promise.reject(new Error('unavailable')); + return Promise.resolve(undefined); + }); + vi.mocked(openFileInOsDefaultApp).mockResolvedValue(true); + + const handler = await getHandler(); + await handler(); + + const htmlPath = vi.mocked(openFileInOsDefaultApp).mock.calls[0][0]; + const html = fs.readFileSync(htmlPath, 'utf8'); + expect(html).toContain('

Objective

'); + }); + + it('reveals the file and warns when the OS cannot open the report', async () => { + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => Promise.resolve(items)); + mockVscode.commands.executeCommand.mockResolvedValue(undefined); + vi.mocked(openFileInOsDefaultApp).mockResolvedValue(false); + + const handler = await getHandler(); + await handler(); + + expect(mockVscode.commands.executeCommand).toHaveBeenCalledWith( + 'revealFileInOS', + expect.anything() + ); + expect(mockVscode.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('.html') + ); + }); + + it('does nothing when the user cancels the QuickPick', async () => { + mockVscode.window.showQuickPick.mockResolvedValueOnce(undefined); + + const handler = await getHandler(); + await handler(); + + expect(openFileInOsDefaultApp).not.toHaveBeenCalled(); + }); + + it('warns and does not open anything when no experiment folder is found', async () => { + mockVscode.window.activeTextEditor = undefined; + mockVscode.workspace.findFiles.mockResolvedValueOnce([]); + + const handler = await getHandler(); + await handler(); + + expect(mockVscode.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('experiment folder') + ); + expect(openFileInOsDefaultApp).not.toHaveBeenCalled(); + }); + }); + + describe('labnotev.exportTablesCsv', () => { + async function getHandler() { + const { registerExportCommands } = await import('../commands/exportCommands'); + registerExportCommands(makeContext()); + const call = mockVscode.commands.registerCommand.mock.calls.find((c) => c[0] === 'labnotev.exportTablesCsv'); + return call?.[1] as () => Promise; + } + + it('writes one CSV per Markdown table into the chosen folder', async () => { + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => Promise.resolve(items)); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'labnotev-csv-out-')); + mockVscode.window.showOpenDialog.mockResolvedValueOnce([{ fsPath: outDir }]); + + const handler = await getHandler(); + await handler(); + + const written = fs.readdirSync(outDir); + expect(written).toContain('001_WL050_Transcriptome_Analysis.csv'); + + const csv = fs.readFileSync(path.join(outDir, '001_WL050_Transcriptome_Analysis.csv'), 'utf8'); + expect(csv).toBe('contrast,total,up,down\n1h_vs_0h,1436,530,906\n'); + + expect(mockVscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('1') + ); + + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it('warns and skips the folder dialog when no tables are found', async () => { + // Select only the README, which has no tables. + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => + Promise.resolve(items.filter((i: any) => i.description === 'README.labnote.md')) + ); + + const handler = await getHandler(); + await handler(); + + expect(mockVscode.window.showOpenDialog).not.toHaveBeenCalled(); + expect(mockVscode.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('table') + ); + }); + + it('does nothing when the user cancels the folder dialog', async () => { + mockVscode.window.showQuickPick.mockImplementationOnce((items: any) => Promise.resolve(items)); + mockVscode.window.showOpenDialog.mockResolvedValueOnce(undefined); + + const handler = await getHandler(); + await handler(); + + expect(mockVscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/lib/exportMarkdown.test.ts b/src/__tests__/lib/exportMarkdown.test.ts new file mode 100644 index 0000000..c188947 --- /dev/null +++ b/src/__tests__/lib/exportMarkdown.test.ts @@ -0,0 +1,248 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { + buildExportCandidates, + collectExportSources, + renderMarkdownFallback, + attachmentLinksToImages, + rewriteImageSources, + postProcessBodyHtml, + buildExportHtml, + type RenderedSource, +} from '../../lib/exportMarkdown'; + +describe('buildExportCandidates', () => { + const readmeAbsPath = '/ws/labnote/001_X/README.labnote.md'; + + it('lists README first, then checklist items in order, then unlisted siblings', () => { + const readme = `--- +title: X +--- +## Related Workflows + +[ ] [001 WD010 Design](./001_WD010_Design.labnote.md) +[x] [002 WB010 Build](./002_WB010_Build.labnote.md) +`; + const siblings = ['001_WD010_Design.labnote.md', '002_WB010_Build.labnote.md', '003_WX010_Extra.labnote.md']; + + const result = buildExportCandidates(readmeAbsPath, readme, siblings); + + expect(result.map(c => c.label)).toEqual([ + 'README.labnote.md', + '001 WD010 Design', + '002 WB010 Build', + '003_WX010_Extra.labnote.md', + ]); + expect(result[0].checked).toBe(true); + expect(result[1].checked).toBe(true); + expect(result[2].checked).toBe(true); + expect(result[3].checked).toBe(false); + }); + + // Regression: real-world README (transcriptome-analysis/labnote/002_pmid_38072358) + // linked the same workflow file twice under Related Workflows. + it('dedupes a workflow file linked twice in the checklist', () => { + const readme = `## Related Workflows + +[ ] [001 RNASeq](./001_WL050_Transcriptome_Analysis.labnote.md) +[ ] [002 DEG analysis](./002_WL050_Transcriptome_Analysis.labnote.md) +[ ] [002 DEG analysis again](./002_WL050_Transcriptome_Analysis.labnote.md) +`; + const siblings = ['001_WL050_Transcriptome_Analysis.labnote.md', '002_WL050_Transcriptome_Analysis.labnote.md']; + + const result = buildExportCandidates(readmeAbsPath, readme, siblings); + + expect(result).toHaveLength(3); // README + 2 unique workflow files + expect(result[2].label).toBe('002 DEG analysis'); // first occurrence wins + }); + + it('ignores checklist links pointing outside the experiment folder', () => { + const readme = `## Related Workflows + +[ ] [Escape](../../../etc/passwd.labnote.md) +`; + const result = buildExportCandidates(readmeAbsPath, readme, []); + expect(result).toHaveLength(1); // README only + }); + + it('ignores checklist links whose target file is missing on disk', () => { + const readme = `## Related Workflows + +[ ] [Ghost](./999_GHOST.labnote.md) +`; + const result = buildExportCandidates(readmeAbsPath, readme, []); + expect(result).toHaveLength(1); + }); +}); + +describe('collectExportSources', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'export-sources-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('reads front matter fields verbatim and strips them from the body', () => { + const filePath = path.join(dir, 'README.labnote.md'); + fs.writeFileSync( + filePath, + `---\ntitle: pmid_39169056\nauthor: Sebin Heo\ncreated_date: 2026-08-04\nlast_updated_date: 2026-08-10\n---\n\n## Objective\n\nBody text.\n`, + 'utf8' + ); + + const [source] = collectExportSources([filePath]); + + expect(source.coverTitle).toBe('pmid_39169056'); + expect(source.coverAuthor).toBe('Sebin Heo'); + expect(source.createdDate).toBe('2026-08-04'); + expect(source.lastUpdatedDate).toBe('2026-08-10'); + expect(source.body).toContain('## Objective'); + expect(source.body).not.toContain('---'); + }); + + it('falls back to experimenter when author is absent (workflow files)', () => { + const filePath = path.join(dir, '001_WL050.labnote.md'); + fs.writeFileSync( + filePath, + `---\ntitle: WL050\nexperimenter: Sebin Heo\ncreated_date: '2026-08-04 14:25'\nlast_updated_date: 2026-08-18\nend_date: ''\n---\n\n## Body\n`, + 'utf8' + ); + + const [source] = collectExportSources([filePath]); + + expect(source.coverAuthor).toBe('Sebin Heo'); + expect(source.createdDate).toBe('2026-08-04 14:25'); // date+time preserved verbatim + }); + + it('dedupes by resolved absolute path and skips missing files', () => { + const filePath = path.join(dir, 'a.labnote.md'); + fs.writeFileSync(filePath, '---\ntitle: A\n---\nBody\n', 'utf8'); + + const result = collectExportSources([filePath, filePath, path.join(dir, 'missing.labnote.md')]); + + expect(result).toHaveLength(1); + }); +}); + +describe('renderMarkdownFallback', () => { + it('converts headings, lists, paragraphs, and images', () => { + const html = renderMarkdownFallback('# Title\n\nSome text.\n\n- one\n- two\n\n![alt](images/x.png)\n'); + expect(html).toContain('

Title

'); + expect(html).toContain('
  • one
  • '); + expect(html).toContain('
  • two
  • '); + expect(html).toContain('

    Some text.

    '); + expect(html).toContain('alt'); + }); +}); + +describe('attachmentLinksToImages', () => { + it('converts a link to an image file into an tag', () => { + const html = 'supp_fig2.png'; + const result = attachmentLinksToImages(html); + expect(result).toBe('supp_fig2.png'); + }); + + it('leaves non-image links untouched', () => { + const html = 'report.pdf'; + expect(attachmentLinksToImages(html)).toBe(html); + }); +}); + +describe('rewriteImageSources', () => { + it('rewrites a relative src to an absolute file:// URI', () => { + const html = ''; + const result = rewriteImageSources(html, '/ws/labnote/001_X'); + expect(result).toBe(''); + }); + + it('leaves absolute http/data/file srcs untouched', () => { + expect(rewriteImageSources('', '/x')).toContain('https://a/b.png'); + expect(rewriteImageSources('', '/x')).toContain('data:image/png'); + }); + + it('decodes percent-encoded paths before resolving', () => { + const html = ''; + const result = rewriteImageSources(html, '/ws/exp'); + const expectedHref = pathToFileURL('/ws/exp/resources/attachments/supp fig.png').href; + expect(result).toContain(expectedHref); + }); +}); + +describe('postProcessBodyHtml', () => { + it('converts attachment image links then absolutizes their src in one pass', () => { + const html = 'fig.png'; + const result = postProcessBodyHtml(html, '/ws/exp'); + expect(result).toBe('fig.png'); + }); +}); + +describe('buildExportHtml', () => { + const labels = { author: 'Author', created: 'Created', lastUpdated: 'Last updated' }; + + function makeSource(overrides: Partial): RenderedSource { + return { + absPath: '/ws/exp/README.labnote.md', + fileName: 'README.labnote.md', + dirPath: '/ws/exp', + coverTitle: 'Untitled', + coverAuthor: '', + createdDate: '', + lastUpdatedDate: '', + body: '', + bodyHtml: '', + ...overrides, + }; + } + + it('renders a cover with title/author/created/last-updated from the first source', () => { + const source = makeSource({ + coverTitle: 'pmid_39169056', + coverAuthor: 'Sebin Heo', + createdDate: '2026-08-04', + lastUpdatedDate: '2026-08-10', + bodyHtml: '

    Body

    ', + }); + + const html = buildExportHtml([source], labels); + + expect(html).toContain('

    pmid_39169056

    '); + expect(html).toContain('Author: Sebin Heo'); + expect(html).toContain('Created: 2026-08-04'); + expect(html).toContain('Last updated: 2026-08-10'); + expect(html).toContain('

    Body

    '); + }); + + it('does not repeat a full cover for additional sources, only a heading + meta line', () => { + const first = makeSource({ coverTitle: 'README', bodyHtml: '

    readme body

    ' }); + const second = makeSource({ + coverTitle: 'WL050 Workflow', + createdDate: '2026-08-04', + lastUpdatedDate: '2026-08-18', + bodyHtml: '

    workflow body

    ', + }); + + const html = buildExportHtml([first, second], labels); + + expect(html.match(/class="cover"/g)).toHaveLength(1); + expect(html).toContain('

    WL050 Workflow

    '); + expect(html).toContain('

    workflow body

    '); + }); + + it('escapes HTML in title and meta fields', () => { + const source = makeSource({ coverTitle: '', bodyHtml: '

    x

    ' }); + const html = buildExportHtml([source], labels); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + + it('returns a minimal document when there are no sources', () => { + const html = buildExportHtml([], labels); + expect(html).toContain(' { + it('extracts a single GFM pipe table with header + data rows', () => { + const md = `#### Output + +| contrast | total | up | down | +|---|---|---|---| +| 1h_vs_0h | 1436 | 530 | 906 | +| 2h_vs_1h | 958 | 534 | 424 | + +Some text after. +`; + const tables = extractMarkdownTables(md); + + expect(tables).toHaveLength(1); + expect(tables[0].rows).toEqual([ + ['contrast', 'total', 'up', 'down'], + ['1h_vs_0h', '1436', '530', '906'], + ['2h_vs_1h', '958', '534', '424'], + ]); + }); + + it('extracts multiple tables in document order and assigns increasing indices', () => { + const md = `| a | b | +|---|---| +| 1 | 2 | + +Some prose. + +| x | y | z | +|---|---|---| +| 3 | 4 | 5 | +`; + const tables = extractMarkdownTables(md); + expect(tables).toHaveLength(2); + expect(tables[0].index).toBe(0); + expect(tables[1].index).toBe(1); + expect(tables[1].rows[0]).toEqual(['x', 'y', 'z']); + }); + + it('returns an empty array when there is no table', () => { + expect(extractMarkdownTables('# Title\n\nJust prose, no pipes.\n')).toEqual([]); + }); + + it('does not misdetect a Related Workflows checklist as a table', () => { + const md = `## Related Workflows + +[ ] [001 WD010 Design](./001_WD010_Design.labnote.md) +[x] [002 WB010 Build](./002_WB010_Build.labnote.md) +`; + expect(extractMarkdownTables(md)).toEqual([]); + }); + + it('handles a table with an alignment row (:---:)', () => { + const md = `| Name | Score | +|:---:|---:| +| A | 1 | +`; + const tables = extractMarkdownTables(md); + expect(tables).toHaveLength(1); + expect(tables[0].rows[0]).toEqual(['Name', 'Score']); + }); + + it('respects an escaped pipe inside a cell', () => { + const md = `| Label | Value | +|---|---| +| a\\|b | 1 | +`; + const tables = extractMarkdownTables(md); + expect(tables[0].rows[1]).toEqual(['a|b', '1']); + }); +}); + +describe('tableToCsv', () => { + it('joins rows with commas and a trailing newline', () => { + const csv = tableToCsv([ + ['contrast', 'total'], + ['1h_vs_0h', '1436'], + ]); + expect(csv).toBe('contrast,total\n1h_vs_0h,1436\n'); + }); + + it('quotes cells containing commas, quotes, or newlines', () => { + const csv = tableToCsv([ + ['a,b', 'say "hi"', 'line1\nline2'], + ]); + expect(csv).toBe('"a,b","say ""hi""","line1\nline2"\n'); + }); +}); diff --git a/src/__tests__/openInOs.test.ts b/src/__tests__/openInOs.test.ts index a388aac..9e46f53 100644 --- a/src/__tests__/openInOs.test.ts +++ b/src/__tests__/openInOs.test.ts @@ -1,4 +1,4 @@ -import { buildOpenCommand } from '../lib/openInOs'; +import { buildOpenCommand, isWsl, toWslWindowsPath } from '../lib/openInOs'; describe('buildOpenCommand', () => { it('uses explorer.exe with the path as a single argument on win32', () => { @@ -29,3 +29,56 @@ describe('buildOpenCommand', () => { expect(args).toEqual(['/tmp/report.xlsx']); }); }); + +// Regression guard: WSL reports process.platform === 'linux' but almost never +// has `xdg-open` installed (no desktop session), so `openFileInOsDefaultApp` +// must detect WSL and route through `explorer.exe` instead — otherwise the +// PDF export report never opens and silently falls back to revealFileInOS. +describe('isWsl', () => { + it('is false on non-linux platforms regardless of env/proc hints', () => { + expect(isWsl('win32', { WSL_DISTRO_NAME: 'Ubuntu' }, () => 'microsoft')).toBe(false); + expect(isWsl('darwin', { WSL_INTEROP: '/run/x' }, () => 'microsoft')).toBe(false); + }); + + it('is true on linux when WSL_DISTRO_NAME is set', () => { + expect(isWsl('linux', { WSL_DISTRO_NAME: 'Ubuntu-24.04' }, () => 'Linux version x')).toBe(true); + }); + + it('is true on linux when WSL_INTEROP is set', () => { + expect(isWsl('linux', { WSL_INTEROP: '/run/WSL/1_interop' }, () => 'Linux version x')).toBe(true); + }); + + it('falls back to sniffing /proc/version for "microsoft" when no env vars are set', () => { + expect(isWsl('linux', {}, () => 'Linux version 6.6.87.2-microsoft-standard-WSL2')).toBe(true); + }); + + it('is false on a regular Linux desktop (no WSL env vars, no "microsoft" in /proc/version)', () => { + expect(isWsl('linux', {}, () => 'Linux version 6.8.0-generic')).toBe(false); + }); + + it('is false when /proc/version cannot be read', () => { + expect( + isWsl('linux', {}, () => { + throw new Error('ENOENT'); + }) + ).toBe(false); + }); +}); + +describe('toWslWindowsPath', () => { + it('returns the trimmed wslpath output', () => { + const result = toWslWindowsPath('/tmp/report.html', () => '\\\\wsl.localhost\\Ubuntu-24.04\\tmp\\report.html\n'); + expect(result).toBe('\\\\wsl.localhost\\Ubuntu-24.04\\tmp\\report.html'); + }); + + it('returns undefined when wslpath throws (not installed / not WSL)', () => { + const result = toWslWindowsPath('/tmp/report.html', () => { + throw new Error('command not found'); + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when wslpath outputs an empty string', () => { + expect(toWslWindowsPath('/tmp/report.html', () => ' ')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/sectionEditorProvider.saveDate.test.ts b/src/__tests__/sectionEditorProvider.saveDate.test.ts new file mode 100644 index 0000000..3df8ab8 --- /dev/null +++ b/src/__tests__/sectionEditorProvider.saveDate.test.ts @@ -0,0 +1,107 @@ +import * as path from 'path'; +import { mockVscode } from './setup'; + +vi.mock('vscode', () => mockVscode); + +const docDir = path.resolve('/workspace/exp01'); + +function makePanel() { + let messageHandler: (msg: unknown) => unknown = () => undefined; + const postMessage = vi.fn(); + const panel = { + webview: { + options: {}, + html: '', + asWebviewUri: vi.fn((u: { fsPath: string }) => ({ toString: () => `webview://${u.fsPath}` })), + cspSource: 'vscode-webview://test', + onDidReceiveMessage: vi.fn((h: (msg: unknown) => unknown) => { + messageHandler = h; + return { dispose: vi.fn() }; + }), + postMessage, + }, + active: true, + onDidChangeViewState: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + }; + return { panel, send: (msg: unknown) => messageHandler(msg), postMessage }; +} + +async function setupProvider(initialContent: string) { + const { SectionEditorProvider } = await import('../sectionEditorProvider'); + const ctx = { subscriptions: [], extensionUri: { fsPath: '/test' }, extensionPath: '/test' } as any; + const provider = new SectionEditorProvider(ctx); + const document = { + uri: { + fsPath: path.join(docDir, 'README.labnote.md'), + toString: () => `file://${path.join(docDir, 'README.labnote.md')}`, + }, + getText: () => initialContent, + positionAt: (o: number) => ({ line: 0, character: o }), + save: vi.fn(() => Promise.resolve(true)), + } as any; + const captured = makePanel(); + await provider.resolveCustomTextEditor(document, captured.panel as any, {} as any); + return captured; +} + +// Regression guard: handleSave previously stamped last_updated_date with +// `new Date().toISOString().split('T')[0]` (UTC), which drifts by a day from +// the Seoul-based dates used at creation (getSeoulDateString) and by the +// updateAllDateFields/updateDateField commands — most visibly right around +// midnight KST. Saving must use the same Seoul convention everywhere so a +// PDF export's "last updated" cover date matches what the user expects. +describe('SectionEditorProvider save date stamping', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('stamps labnote last_updated_date using Seoul date, not UTC', async () => { + const { getSeoulDateString } = await import('../lib/dateUtils'); + const expected = getSeoulDateString(); + + const { send } = await setupProvider('---\nexperiment_type: labnote\n---\n'); + + await send({ + type: 'save', + data: { + labNote: { + frontMatter: { title: 'T', author: 'A', experiment_type: 'labnote', sample_tracking: false }, + sections: [], + }, + }, + }); + + const applyEditCalls = (mockVscode.workspace.applyEdit as ReturnType).mock.calls; + expect(applyEditCalls.length).toBeGreaterThan(0); + const workspaceEdit = applyEditCalls[0][0]; + const replacedText: string = workspaceEdit.replace.mock.calls[0][2]; + expect(replacedText).toContain(`last_updated_date: ${expected}`); + }); + + it('stamps workflow last_updated_date using Seoul date, not UTC', async () => { + const { getSeoulDateString } = await import('../lib/dateUtils'); + const expected = getSeoulDateString(); + + const { send } = await setupProvider('---\ntitle: WD010 Test\nexperimenter: Tester\n---\n\n## [WD010 Test]\n'); + + await send({ + type: 'save', + data: { + workflow: { + frontMatter: { title: 'WD010 Test', experimenter: 'Tester', created_date: '2026-01-01', end_date: '' }, + workflowHeader: '[WD010 Test]', + workflowDescription: '', + unitOperations: [], + tailContent: '', + }, + }, + }); + + const applyEditCalls = (mockVscode.workspace.applyEdit as ReturnType).mock.calls; + expect(applyEditCalls.length).toBeGreaterThan(0); + const workspaceEdit = applyEditCalls[0][0]; + const replacedText: string = workspaceEdit.replace.mock.calls[0][2]; + expect(replacedText).toContain(`last_updated_date: ${expected}`); + }); +}); diff --git a/src/commands/exportCommands.ts b/src/commands/exportCommands.ts new file mode 100644 index 0000000..9e1d5fe --- /dev/null +++ b/src/commands/exportCommands.ts @@ -0,0 +1,198 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isValidReadmePath, isValidWorkflowPath } from '../lib/workflowStructure'; +import { + buildExportCandidates, + collectExportSources, + renderMarkdownFallback, + buildExportHtml, + type ExportCandidate, + type RenderedSource, +} from '../lib/exportMarkdown'; +import { extractMarkdownTables, tableToCsv } from '../lib/exportTablesCsv'; +import { openFileInOsDefaultApp } from '../lib/openInOs'; +import type { SectionEditorProvider } from '../sectionEditorProvider'; + +export interface ExportCommandDeps { + sectionEditorProvider?: SectionEditorProvider; +} + +/** Strips characters that are illegal in file names on Windows/macOS/Linux, keeping unicode (e.g. Korean titles) intact. */ +function sanitizeFileNameSegment(name: string): string { + const cleaned = name.replace(/[\\/:*?"<>|]/g, '_').trim(); + return cleaned || 'labnote-export'; +} + +const WORKFLOW_FILE_RE = /^\d{3}_.+\.labnote\.md$/i; + +/** + * Resolves the README.labnote.md to export from, preferring the active + * editor/Section Editor document, then falling back to a workspace-wide + * search (prompting when more than one experiment folder exists). + */ +async function resolveReadmePath(sectionEditorProvider?: SectionEditorProvider): Promise { + const fromPath = (fsPath: string | undefined): string | undefined => { + if (!fsPath) return undefined; + if (isValidReadmePath(fsPath) && fs.existsSync(fsPath)) return fsPath; + if (isValidWorkflowPath(fsPath)) { + const candidate = path.join(path.dirname(fsPath), 'README.labnote.md'); + return fs.existsSync(candidate) ? candidate : undefined; + } + return undefined; + }; + + const active = + fromPath(vscode.window.activeTextEditor?.document.uri.fsPath) ?? + fromPath(sectionEditorProvider?.getActiveDocument()?.uri.fsPath); + if (active) return active; + + const found = await vscode.workspace.findFiles('**/labnote/*/README.labnote.md', '**/node_modules/**'); + if (found.length === 0) return undefined; + if (found.length === 1) return found[0].fsPath; + + const picked = await vscode.window.showQuickPick( + found.map((uri) => ({ + label: path.basename(path.dirname(uri.fsPath)), + description: uri.fsPath, + uri, + })), + { placeHolder: vscode.l10n.t('Select an experiment folder to export') } + ); + return picked?.uri.fsPath; +} + +/** + * Shared QuickPick used by both export commands: pick an experiment (if + * needed), then choose which of its lab note files to include. + */ +async function pickExportSources(sectionEditorProvider?: SectionEditorProvider): Promise { + const readmePath = await resolveReadmePath(sectionEditorProvider); + if (!readmePath) { + vscode.window.showWarningMessage(vscode.l10n.t('No lab note experiment folder was found in this workspace.')); + return undefined; + } + + const dir = path.dirname(readmePath); + const readmeContent = fs.readFileSync(readmePath, 'utf8'); + const siblingFileNames = fs + .readdirSync(dir) + .filter((f) => WORKFLOW_FILE_RE.test(f) && f.toLowerCase() !== 'readme.labnote.md'); + const candidates = buildExportCandidates(readmePath, readmeContent, siblingFileNames); + + const items = candidates.map((candidate) => ({ + label: candidate.label, + description: path.basename(candidate.absPath), + picked: candidate.checked, + candidate, + })); + + const selected = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: vscode.l10n.t('Select the lab note files to include'), + }); + if (!selected) return undefined; + if (selected.length === 0) { + vscode.window.showWarningMessage(vscode.l10n.t('Select at least one file to export.')); + return undefined; + } + + return selected.map((item: { candidate: ExportCandidate }) => item.candidate.absPath); +} + +export function registerExportCommands(context: vscode.ExtensionContext, deps?: ExportCommandDeps): void { + const { sectionEditorProvider } = deps ?? {}; + + context.subscriptions.push( + vscode.commands.registerCommand('labnotev.exportPdf', async () => { + const paths = await pickExportSources(sectionEditorProvider); + if (!paths) return; + + const collected = collectExportSources(paths); + if (collected.length === 0) { + vscode.window.showWarningMessage(vscode.l10n.t('No exportable lab note files were found.')); + return; + } + + const rendered: RenderedSource[] = []; + for (const source of collected) { + let bodyHtml: string; + try { + const result = await vscode.commands.executeCommand('markdown.api.render', source.body); + bodyHtml = typeof result === 'string' ? result : renderMarkdownFallback(source.body); + } catch { + bodyHtml = renderMarkdownFallback(source.body); + } + rendered.push({ ...source, bodyHtml }); + } + + const html = buildExportHtml(rendered, { + author: vscode.l10n.t('Author'), + created: vscode.l10n.t('Created'), + lastUpdated: vscode.l10n.t('Last updated'), + }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'labnotev-export-')); + const htmlPath = path.join(tmpDir, `${sanitizeFileNameSegment(rendered[0].coverTitle)}.html`); + fs.writeFileSync(htmlPath, html, 'utf8'); + + if (await openFileInOsDefaultApp(htmlPath)) { + vscode.window.showInformationMessage( + vscode.l10n.t('Report opened in your browser. Use Print (Ctrl/Cmd+P) to save it as a PDF.') + ); + return; + } + + try { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(htmlPath)); + } catch { + // command may be unavailable in some hosts + } + vscode.window.showWarningMessage( + vscode.l10n.t('Could not open the report automatically. Open {0} in a browser and print it as a PDF.', htmlPath) + ); + }), + + vscode.commands.registerCommand('labnotev.exportTablesCsv', async () => { + const paths = await pickExportSources(sectionEditorProvider); + if (!paths) return; + + const collected = collectExportSources(paths); + const entries = collected.flatMap((source) => + extractMarkdownTables(source.body).map((table) => ({ source, table })) + ); + if (entries.length === 0) { + vscode.window.showWarningMessage(vscode.l10n.t('No Markdown tables were found in the selected files.')); + return; + } + + const destination = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: vscode.l10n.t('Choose Folder'), + }); + if (!destination || destination.length === 0) return; + const outDir = destination[0].fsPath; + + const writtenPaths: string[] = []; + for (const { source, table } of entries) { + const baseName = sanitizeFileNameSegment(path.basename(source.fileName, '.labnote.md')); + const suffix = table.index > 0 ? `_table${table.index + 1}` : ''; + const outPath = path.join(outDir, `${baseName}${suffix}.csv`); + fs.writeFileSync(outPath, tableToCsv(table.rows), 'utf8'); + writtenPaths.push(outPath); + } + + vscode.window.showInformationMessage( + vscode.l10n.t('Exported {0} table(s) as CSV to {1}.', String(writtenPaths.length), outDir) + ); + try { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(writtenPaths[0])); + } catch { + // command may be unavailable in some hosts + } + }) + ); +} diff --git a/src/commands/index.ts b/src/commands/index.ts index cfedb2f..1c0b9b7 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,3 +5,5 @@ export type { WorkflowCommandProviders } from './workflowCommands'; export { registerUtilityCommands } from './utilityCommands'; export type { UtilityCommandProviders } from './utilityCommands'; export { registerCreationCommands } from './creationCommands'; +export { registerExportCommands } from './exportCommands'; +export type { ExportCommandDeps } from './exportCommands'; diff --git a/src/commands/workflowCommands.ts b/src/commands/workflowCommands.ts index b9ffa33..19b9b36 100644 --- a/src/commands/workflowCommands.ts +++ b/src/commands/workflowCommands.ts @@ -536,7 +536,7 @@ export function registerWorkflowCommands( const readmeContent = readmeDoc.getText(); const items = parseWorkflowChecklistFromReadme(readmeContent); - let diskWorkflowFiles: string[] = []; + let diskWorkflowFiles: string[]; try { diskWorkflowFiles = fs .readdirSync(dirPath) diff --git a/src/extension.ts b/src/extension.ts index 469a55b..b8ceac9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { registerWorkflowCommands, registerUtilityCommands, registerCreationCommands, + registerExportCommands, } from './commands'; import { SectionEditorProvider } from './sectionEditorProvider'; import { disposeDecorations } from './lib/sampleDecorations'; @@ -176,6 +177,8 @@ export async function activate(context: vscode.ExtensionContext) { registerCreationCommands(context, { sectionEditorProvider }); + registerExportCommands(context, { sectionEditorProvider }); + // Watch `resources/labsamples/*.json` for any change/create/delete and // refresh both the TreeView and every live Section Editor. This catches // edits made outside the extension (manual JSON edits, git pulls, etc.) diff --git a/src/lib/exportMarkdown.ts b/src/lib/exportMarkdown.ts new file mode 100644 index 0000000..e254039 --- /dev/null +++ b/src/lib/exportMarkdown.ts @@ -0,0 +1,306 @@ +/** + * PDF/CSV export helpers - pure functions (no `vscode` import) so they can be + * unit tested directly. Command handlers in `commands/exportCommands.ts` do + * the file-system and VS Code API calls and pass plain data in. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { resolveContainedPath } from './isPathInsideDir'; +import { parseWorkflowChecklistFromReadme } from './workflowStructure'; + +// --------------------------------------------------------------------------- +// Candidate selection (which files can be exported from an experiment folder) +// --------------------------------------------------------------------------- + +export interface ExportCandidate { + absPath: string; + label: string; + /** Default checkbox state offered to the user in the QuickPick. */ + checked: boolean; +} + +/** + * Builds the list of files a user can pick from when exporting an experiment + * folder: the README itself, then Related Workflows checklist entries (in + * checklist order, pre-checked), then any other `###_*.labnote.md` sibling + * files not on the checklist (unchecked). Deduplicated by resolved absolute + * path — a README checklist can reference the same workflow file twice. + */ +export function buildExportCandidates( + readmeAbsPath: string, + readmeContent: string, + siblingFileNames: string[] +): ExportCandidate[] { + const dir = path.dirname(readmeAbsPath); + const readmeResolved = path.resolve(readmeAbsPath); + const candidates: ExportCandidate[] = [ + { absPath: readmeResolved, label: path.basename(readmeAbsPath), checked: true }, + ]; + const seen = new Set([readmeResolved]); + + for (const item of parseWorkflowChecklistFromReadme(readmeContent)) { + const abs = resolveContainedPath(dir, item.fileName); + if (!abs || seen.has(abs) || !siblingFileNames.includes(path.basename(abs))) continue; + seen.add(abs); + candidates.push({ absPath: abs, label: item.title, checked: true }); + } + + for (const fileName of [...siblingFileNames].sort()) { + if (fileName.toLowerCase() === 'readme.labnote.md') continue; + const abs = path.resolve(dir, fileName); + if (seen.has(abs)) continue; + seen.add(abs); + candidates.push({ absPath: abs, label: fileName, checked: false }); + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// Reading sources: front matter for the cover, raw body for rendering +// --------------------------------------------------------------------------- + +export interface ExportSource { + absPath: string; + fileName: string; + dirPath: string; + coverTitle: string; + coverAuthor: string; + createdDate: string; + lastUpdatedDate: string; + /** Markdown body (front matter stripped), unchanged from disk. */ + body: string; +} + +function extractFrontMatterFields(raw: string): { fields: Record; body: string } { + const normalized = raw.replace(/\r\n/g, '\n'); + const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/); + if (!match) return { fields: {}, body: normalized }; + + const fields: Record = {}; + for (const line of match[1].split('\n')) { + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + if (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) { + value = value.slice(1, -1); + } + fields[key] = value; + } + return { fields, body: normalized.slice(match[0].length).replace(/^\n+/, '') }; +} + +/** + * Reads and dedupes (by resolved absolute path) each requested file. Cover + * fields are read as plain strings from front matter and never recomputed — + * created/last-updated dates must match exactly what is on disk. + */ +export function collectExportSources(absPaths: string[]): ExportSource[] { + const seen = new Set(); + const sources: ExportSource[] = []; + for (const p of absPaths) { + const resolved = path.resolve(p); + if (seen.has(resolved) || !fs.existsSync(resolved)) continue; + seen.add(resolved); + + const raw = fs.readFileSync(resolved, 'utf8'); + const { fields, body } = extractFrontMatterFields(raw); + sources.push({ + absPath: resolved, + fileName: path.basename(resolved), + dirPath: path.dirname(resolved), + coverTitle: fields.title || path.basename(resolved), + coverAuthor: fields.author || fields.experimenter || '', + createdDate: fields.created_date || '', + lastUpdatedDate: fields.last_updated_date || '', + body, + }); + } + return sources; +} + +// --------------------------------------------------------------------------- +// Markdown -> HTML fallback (only used if `markdown.api.render` is unavailable) +// --------------------------------------------------------------------------- + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Minimal, best-effort Markdown -> HTML conversion (headings, paragraphs, + * `- ` lists, `![]()` images). Used only when the built-in + * `markdown.api.render` command cannot be executed (e.g. the bundled + * markdown extension is disabled) so export still produces something. + */ +export function renderMarkdownFallback(markdown: string): string { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const out: string[] = []; + let inList = false; + const closeList = () => { + if (inList) { + out.push(''); + inList = false; + } + }; + + for (const line of lines) { + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + closeList(); + const level = heading[1].length; + out.push(`${escapeHtml(heading[2])}`); + continue; + } + + const listItem = line.match(/^\s*[-*]\s+(.*)$/); + if (listItem) { + if (!inList) { + out.push('
      '); + inList = true; + } + out.push(`
    • ${escapeHtml(listItem[1])}
    • `); + continue; + } + + closeList(); + if (line.trim() === '') continue; + + const withImages = escapeHtml(line).replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + (_m, alt, src) => `${alt}` + ); + out.push(`

      ${withImages}

      `); + } + closeList(); + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// Post-processing rendered HTML: attachment image links + absolute file:// srcs +// --------------------------------------------------------------------------- + +const IMAGE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp)(?:[?#].*)?$/i; + +/** Turns attachment-style links (`foo.png`) into `` tags. */ +export function attachmentLinksToImages(html: string): string { + return html.replace( + /]*?)href="([^"]+)"([^>]*)>([^<]*)<\/a>/gi, + (match, _before, href, _after, text) => { + if (!IMAGE_EXT_RE.test(href)) return match; + return `${text.trim()}`; + } + ); +} + +/** + * Rewrites relative `` values to absolute `file://` URIs resolved + * against `baseDir`. The report is opened as a plain file in the user's + * browser (not a VS Code webview), so no CSP/asWebviewUri handling is needed — + * just a correct absolute path. + */ +export function rewriteImageSources(html: string, baseDir: string): string { + return html.replace(/(]*\bsrc=")([^"]+)(")/gi, (match, pre, src, post) => { + if (/^(?:https?:|data:|file:)/i.test(src)) return match; + try { + const abs = path.resolve(baseDir, decodeURIComponent(src)); + return `${pre}${pathToFileURL(abs).href}${post}`; + } catch { + return match; + } + }); +} + +export function postProcessBodyHtml(html: string, baseDir: string): string { + return rewriteImageSources(attachmentLinksToImages(html), baseDir); +} + +// --------------------------------------------------------------------------- +// Final HTML document assembly +// --------------------------------------------------------------------------- + +export interface RenderedSource extends ExportSource { + /** Result of `markdown.api.render(source.body)` (or the fallback renderer). */ + bodyHtml: string; +} + +export interface CoverLabels { + author: string; + created: string; + lastUpdated: string; +} + +const PRINT_CSS = ` + @page { margin: 18mm 16mm; } + body { + font-family: "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; + color: #1a1a1a; + max-width: 800px; + margin: 0 auto; + line-height: 1.5; + } + h1, h2, h3, h4 { line-height: 1.3; } + .cover { margin-bottom: 24px; } + .cover h1 { margin-bottom: 4px; } + .meta { color: #555; font-size: 0.9em; } + .source { margin-top: 24px; } + table { border-collapse: collapse; width: 100%; margin: 12px 0; } + th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; } + thead { display: table-header-group; } + tr, img, pre { break-inside: avoid; page-break-inside: avoid; } + img { max-width: 100%; } + pre { white-space: pre-wrap; background: #f5f5f5; padding: 8px; border-radius: 4px; } + code { background: #f5f5f5; padding: 0 3px; border-radius: 3px; } + blockquote { border-left: 3px solid #ccc; padding-left: 8px; color: #555; margin-left: 0; } +`; + +function renderMetaLine(source: ExportSource, labels: CoverLabels): string { + return `

      ${labels.author}: ${escapeHtml(source.coverAuthor)} · ${labels.created}: ${escapeHtml(source.createdDate)} · ${labels.lastUpdated}: ${escapeHtml(source.lastUpdatedDate)}

      `; +} + +/** + * Assembles the full export HTML: a cover block from the first source (its + * title/author/created/last-updated, taken verbatim from front matter), the + * first source's body, then each remaining source under its own heading and + * meta line so multi-file exports still show accurate dates per file. + */ +export function buildExportHtml(sources: RenderedSource[], labels: CoverLabels): string { + if (sources.length === 0) { + return ''; + } + + const [first, ...rest] = sources; + const firstBody = postProcessBodyHtml(first.bodyHtml, first.dirPath); + + const restHtml = rest + .map((source) => { + const body = postProcessBodyHtml(source.bodyHtml, source.dirPath); + return `
      +

      ${escapeHtml(source.coverTitle)}

      + ${renderMetaLine(source, labels)} + ${body} +
      `; + }) + .join('\n'); + + return ` + + + +${escapeHtml(first.coverTitle)} + + + +
      +

      ${escapeHtml(first.coverTitle)}

      + ${renderMetaLine(first, labels)} +
      +
      +${firstBody} +
      +${restHtml} + +`; +} diff --git a/src/lib/exportTablesCsv.ts b/src/lib/exportTablesCsv.ts new file mode 100644 index 0000000..52183b2 --- /dev/null +++ b/src/lib/exportTablesCsv.ts @@ -0,0 +1,82 @@ +/** + * Extracts GFM pipe tables from a Markdown body and converts them to CSV. + * Pure functions, no `vscode` import. + */ + +export interface CsvTable { + /** 0-based index of the table within the source document (0 = first table). */ + index: number; + /** Rows including the header row (the `---` separator row is dropped). */ + rows: string[][]; +} + +const SEPARATOR_ROW_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/; + +function stripEdgePipes(line: string): string { + let s = line.trim(); + if (s.startsWith('|')) s = s.slice(1); + if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1); + return s; +} + +/** Splits a single pipe-table row into cells, honoring `\|` as an escaped literal pipe. */ +function splitTableRow(line: string): string[] { + const stripped = stripEdgePipes(line); + const cells: string[] = []; + let current = ''; + for (let i = 0; i < stripped.length; i++) { + const ch = stripped[i]; + if (ch === '\\' && stripped[i + 1] === '|') { + current += '|'; + i++; + continue; + } + if (ch === '|') { + cells.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + cells.push(current.trim()); + return cells; +} + +/** + * Finds every GFM pipe table in `markdown` (header row + `---` separator + + * zero or more data rows). Tables are matched in document order. + */ +export function extractMarkdownTables(markdown: string): CsvTable[] { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const tables: CsvTable[] = []; + + let i = 0; + while (i < lines.length) { + const isHeaderLine = lines[i].trim().startsWith('|'); + const nextIsSeparator = i + 1 < lines.length && SEPARATOR_ROW_RE.test(lines[i + 1]); + if (isHeaderLine && nextIsSeparator) { + const rows: string[][] = [splitTableRow(lines[i])]; + i += 2; + while (i < lines.length && lines[i].trim().startsWith('|')) { + rows.push(splitTableRow(lines[i])); + i++; + } + tables.push({ index: tables.length, rows }); + continue; + } + i++; + } + + return tables; +} + +function escapeCsvCell(cell: string): string { + if (/[",\n]/.test(cell)) { + return `"${cell.replace(/"/g, '""')}"`; + } + return cell; +} + +export function tableToCsv(rows: string[][]): string { + return rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n') + '\n'; +} diff --git a/src/lib/openInOs.ts b/src/lib/openInOs.ts index 67bc06d..20d143b 100644 --- a/src/lib/openInOs.ts +++ b/src/lib/openInOs.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcess } from 'child_process'; +import { spawn, execFileSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; /** * Build the OS launcher command + argv for opening `filePath` with the default @@ -24,16 +25,63 @@ export function buildOpenCommand( return { command: 'xdg-open', args: [filePath] }; } +/** + * WSL reports `process.platform === 'linux'` but has no desktop session, so + * the `xdg-open` used for regular Linux is normally not installed and always + * fails. Detected via the WSL-specific env vars set by the Windows interop + * layer, falling back to sniffing `/proc/version` (present on any real Linux + * kernel, so this never throws outside of exotic sandboxes — guarded anyway). + */ +export function isWsl( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + readProcVersion: () => string = () => fs.readFileSync('/proc/version', 'utf8') +): boolean { + if (platform !== 'linux') return false; + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true; + try { + return /microsoft/i.test(readProcVersion()); + } catch { + return false; + } +} + +/** + * Converts a WSL-side path (e.g. `/tmp/x.html`) to the `\\wsl.localhost\...` + * form Windows can open, via the `wslpath` utility that ships with WSL. + * Returns `undefined` if `wslpath` is unavailable or fails, so callers can + * fall back to the regular `xdg-open` attempt. + */ +export function toWslWindowsPath( + filePath: string, + runWslpath: (p: string) => string = (p) => execFileSync('wslpath', ['-w', p], { encoding: 'utf8' }) +): string | undefined { + try { + const converted = runWslpath(filePath).trim(); + return converted || undefined; + } catch { + return undefined; + } +} + /** * Open a local file with the OS default application (ShellExecute / open / xdg-open). * Avoids vscode.env.openExternal(file: URI) on Windows, which can fail with 0x2 for paths * containing non-ASCII characters while still showing VS Code's own error dialog. + * + * On WSL, routes through `explorer.exe` with a `wslpath`-translated Windows + * path — the same mechanism as native Windows — instead of the usually-missing + * `xdg-open`, so a plain `xdg-open` failure there doesn't unnecessarily fall + * back to `revealFileInOS`. */ export async function openFileInOsDefaultApp(filePath: string): Promise { return new Promise((resolve) => { let child: ChildProcess | undefined; try { - const { command, args } = buildOpenCommand(process.platform, filePath); + const wslWinPath = isWsl() ? toWslWindowsPath(filePath) : undefined; + const { command, args } = wslWinPath !== undefined + ? { command: 'explorer.exe', args: [wslWinPath] } + : buildOpenCommand(process.platform, filePath); child = spawn(command, args, { detached: true, stdio: 'ignore' }); } catch { resolve(false); diff --git a/src/sectionEditorProvider.ts b/src/sectionEditorProvider.ts index 1b16a6b..862bcbf 100644 --- a/src/sectionEditorProvider.ts +++ b/src/sectionEditorProvider.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import { parseLabNoteMd, serializeLabNoteMd } from './lib/labnoteSectionParser'; import { parseWorkflowMd, serializeWorkflowMd } from './lib/workflowSectionParser'; import type { UnitOperationBlock, WorkflowReference } from './lib/sectionTypes'; -import { getSeoulDateTimeString } from './lib/dateUtils'; +import { getSeoulDateString, getSeoulDateTimeString } from './lib/dateUtils'; import { isValidSectionEditorMessage } from './lib/webviewMessage'; import { generateSampleId, getSampleDisplayMeta, buildSampleDefSuffix, type SampleDisplayMeta } from './lib/sampleUtils'; import { findSampleDefinitionOnlyMatch } from './lib/sampleStorage'; @@ -921,7 +921,7 @@ export class SectionEditorProvider implements vscode.CustomTextEditorProvider { this._suppressedDocs.add(docKey); try { if (mode === 'labnote' && data.labNote) { - data.labNote.frontMatter.last_updated_date = new Date().toISOString().split('T')[0]; + data.labNote.frontMatter.last_updated_date = getSeoulDateString(); const newContent = serializeLabNoteMd(data.labNote); const edit = new vscode.WorkspaceEdit(); const fullRange = new vscode.Range( @@ -936,7 +936,7 @@ export class SectionEditorProvider implements vscode.CustomTextEditorProvider { await this.writeChangedWorkflows(document, data.changedWorkflows); } } else if (mode === 'workflow' && data.workflow) { - data.workflow.frontMatter.last_updated_date = new Date().toISOString().split('T')[0]; + data.workflow.frontMatter.last_updated_date = getSeoulDateString(); const newContent = serializeWorkflowMd(data.workflow); const edit = new vscode.WorkspaceEdit(); const fullRange = new vscode.Range(