Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 명령으로 실험 제목과 작성자 입력
Expand Down Expand Up @@ -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로 추출 |

## 문제 해결

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
}
);
16 changes: 15 additions & 1 deletion l10n/bundle.l10n.ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}에 내보냈습니다."
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 3 additions & 1 deletion package.nls.ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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로 내보내기"
}
239 changes: 239 additions & 0 deletions src/__tests__/exportCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof makeExperiment>;

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<void>;
}

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(`<p>${arg.trim()}</p>`);
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('<h2>Objective</h2>');
});

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<void>;
}

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();
});
});
});
Loading