From 1bd0b7ef1a115c6928fc12623a15b7a57c471eb7 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Tue, 27 May 2025 17:41:37 +0800 Subject: [PATCH 01/13] chore: add GitHub Actions workflow for pre-releasing to NPM from develop branch --- .github/workflows/pre-release.yml | 85 +++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/pre-release.yml diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..de60d8b --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,85 @@ +name: Pre-release to NPM + +on: + push: + branches: + - develop + +jobs: + pre-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + cache-dependency-path: nodejs/package-lock.json + + - name: Install dependencies + working-directory: nodejs + run: npm ci + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Generate pre-release version + working-directory: nodejs + run: | + # Get current version from package.json + CURRENT_VERSION=$(node -p "require('./package.json').version") + + # Get short commit hash + SHORT_SHA=$(git rev-parse --short HEAD) + + # Get current timestamp + TIMESTAMP=$(date +%Y%m%d%H%M%S) + + # Create pre-release version: current-version-beta.timestamp.sha + PRE_RELEASE_VERSION="${CURRENT_VERSION}-beta.${TIMESTAMP}.${SHORT_SHA}" + + echo "Pre-release version: $PRE_RELEASE_VERSION" + echo "PRE_RELEASE_VERSION=$PRE_RELEASE_VERSION" >> $GITHUB_ENV + + # Update package.json with pre-release version + npm version $PRE_RELEASE_VERSION --no-git-tag-version + + - name: Build + working-directory: nodejs + run: npm run build + + - name: Publish pre-release to NPM + working-directory: nodejs + run: npm publish --tag beta --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub pre-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ env.PRE_RELEASE_VERSION }}" \ + --title "Pre-release v${{ env.PRE_RELEASE_VERSION }}" \ + --notes "🚀 **Pre-release from develop branch** + + This is an automated pre-release build from the develop branch. + + **Changes:** + - Commit: ${{ github.sha }} + - Branch: ${{ github.ref_name }} + + **Installation:** + \`\`\`bash + npm install @hackmd/api@beta + \`\`\` + + **Note:** This is a pre-release version and may contain unstable features." \ + --prerelease \ No newline at end of file From 8955c649f3b290d57499caa93daa5c6fcbd1ad8e Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Tue, 27 May 2025 17:41:43 +0800 Subject: [PATCH 02/13] chore: enhance GitHub Actions workflow to extract version from tags and create draft releases --- .github/workflows/publish.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc8e584..3676490 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v4 @@ -31,4 +33,19 @@ jobs: working-directory: nodejs run: npm publish --access public env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Extract version from tag + run: | + # Extract version from tag (remove 'v' prefix) + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "Extracted version: $VERSION" + + - name: Create draft release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "Release v${{ env.VERSION }}" \ + --draft \ No newline at end of file From 5c401ea8221099cea249992069f4e524cf2828e5 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Tue, 27 May 2025 17:53:41 +0800 Subject: [PATCH 03/13] chore: remove fetch-depth configuration from GitHub Actions workflow for cleaner setup --- .github/workflows/publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3676490..d0cbd90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,8 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v4 From 45afcc498c1e1474e2cf575ce6b4895e4d80c1e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:37:30 +0000 Subject: [PATCH 04/13] Initial plan for issue From ee365506332164c97464d46a8916bec923dcce10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:40:35 +0000 Subject: [PATCH 05/13] Initial assessment and planning for book mode conference note example Co-authored-by: Yukaii <4230968+Yukaii@users.noreply.github.com> --- examples/nodejs/package-lock.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/nodejs/package-lock.json b/examples/nodejs/package-lock.json index 165b707..617f983 100644 --- a/examples/nodejs/package-lock.json +++ b/examples/nodejs/package-lock.json @@ -22,16 +22,20 @@ }, "devDependencies": { "@faker-js/faker": "^7.6.0", + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", "@types/eslint": "^8.21.0", "@types/jest": "^29.4.0", "@types/node": "^13.11.1", - "@typescript-eslint/eslint-plugin": "^5.52.0", - "@typescript-eslint/parser": "^5.52.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "dotenv": "^16.0.3", - "eslint": "^8.9.0", + "eslint": "^8.57.1", "jest": "^29.4.2", "msw": "^2.7.3", "rimraf": "^4.1.2", + "rollup": "^4.41.1", "ts-jest": "^29.0.5", "ts-node": "^8.8.2", "typescript": "^4.9.5" From 733f86b136456a4b8b5183c2a51991438c6b0cb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:47:55 +0000 Subject: [PATCH 06/13] Add book mode conference note example with TypeScript implementation Co-authored-by: Yukaii <4230968+Yukaii@users.noreply.github.com> --- README.md | 21 + examples/book-mode-conference/.env.example | 8 + examples/book-mode-conference/README.md | 288 ++++++++ examples/book-mode-conference/index.ts | 413 +++++++++++ .../book-mode-conference/package-lock.json | 646 ++++++++++++++++++ examples/book-mode-conference/package.json | 23 + examples/book-mode-conference/sessions.json | 133 ++++ examples/book-mode-conference/tsconfig.json | 18 + 8 files changed, 1550 insertions(+) create mode 100644 examples/book-mode-conference/.env.example create mode 100644 examples/book-mode-conference/README.md create mode 100644 examples/book-mode-conference/index.ts create mode 100644 examples/book-mode-conference/package-lock.json create mode 100644 examples/book-mode-conference/package.json create mode 100644 examples/book-mode-conference/sessions.json create mode 100644 examples/book-mode-conference/tsconfig.json diff --git a/README.md b/README.md index 5c8f233..0b890b7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,27 @@ To run the Node.js example: The example includes detailed comments and demonstrates best practices for using the HackMD API client. +### Book Mode Conference Note Example + +The `examples/book-mode-conference/` directory contains a TypeScript example for creating a "book mode" conference note system: + +- **Book Mode Notes**: Creates a master note that links to all session notes +- **Bulk Note Creation**: Automatically creates individual notes for each conference session +- **TypeScript Implementation**: Full type safety with tsx support for direct execution +- **Configurable Templates**: Customizable note templates and conference settings +- **Hierarchical Organization**: Sessions organized by day and time in the main book +- **Error Handling**: Graceful handling of API failures during bulk operations + +To run the book mode conference example: + +1. Navigate to the example directory: `cd examples/book-mode-conference` +2. Follow the setup instructions in [examples/book-mode-conference/README.md](./examples/book-mode-conference/README.md) +3. Customize the configuration constants and session data +4. Set your HackMD access token +5. Run `npm start` + +This example demonstrates advanced usage patterns including bulk operations, team note management, and creating interconnected note structures for conferences or events. + ## LICENSE MIT diff --git a/examples/book-mode-conference/.env.example b/examples/book-mode-conference/.env.example new file mode 100644 index 0000000..5b7704b --- /dev/null +++ b/examples/book-mode-conference/.env.example @@ -0,0 +1,8 @@ +# HackMD API Configuration +# Get your access token from: https://hackmd.io/@hackmd-api/developer-portal + +# Required: Your HackMD access token +HACKMD_ACCESS_TOKEN=your_access_token_here + +# Optional: HackMD API endpoint (defaults to https://api.hackmd.io/v1) +HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 \ No newline at end of file diff --git a/examples/book-mode-conference/README.md b/examples/book-mode-conference/README.md new file mode 100644 index 0000000..5b5f20b --- /dev/null +++ b/examples/book-mode-conference/README.md @@ -0,0 +1,288 @@ +# Book Mode Conference Note Generator + +This example demonstrates how to create a "book mode" conference note system using the HackMD API. Book mode is a Markdown note that contains organized links to each session note page, making it easy for conference attendees to navigate between different session notes. + +## What This Example Does + +The script performs the following actions: + +1. **Loads Session Data**: Reads conference session information from `sessions.json` +2. **Creates Individual Session Notes**: For each session, creates a dedicated HackMD note with: + - Session title and speaker information + - Embedded announcement note + - Sections for notes, discussion, and related links + - Appropriate tags and permissions +3. **Generates Main Book Note**: Creates a master note that: + - Contains welcome information and useful links + - Organizes all session notes by day and time + - Provides easy navigation to all sessions + - Serves as a central hub for the conference + +## Features + +- **TypeScript Implementation**: Written in TypeScript with full type safety +- **Configurable Constants**: All configuration is centralized at the top of the file +- **Comprehensive Comments**: Well-documented code explaining each section +- **Error Handling**: Graceful handling of API failures +- **tsx Support**: Can be run directly without compilation using tsx +- **Modular Design**: Functions are exportable for potential reuse +- **Flexible Session Data**: Supports various session types and multilingual content + +## Setup + +### Prerequisites + +- Node.js (version 16 or higher) +- A HackMD account with API access +- Access to a HackMD team (for creating team notes) + +### Installation + +1. **Build the main HackMD API package** (if not already done): + ```bash + cd ../../nodejs + npm install + npm run build + cd ../examples/book-mode-conference + ``` + +2. **Install dependencies**: + ```bash + npm install + ``` + +3. **Configure your HackMD access token**: + + **Option A: Environment Variable** + ```bash + # For Unix/Linux/macOS + export HACKMD_ACCESS_TOKEN=your_access_token_here + + # For Windows PowerShell + $env:HACKMD_ACCESS_TOKEN="your_access_token_here" + ``` + + **Option B: .env File** + ```bash + cp .env.example .env + # Edit .env and add your access token + ``` + + You can get your access token from the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). + +### Configuration + +Before running the script, you may want to customize the configuration constants at the top of `index.ts`: + +#### Essential Configuration + +```typescript +// HackMD announcement note to embed in each session note +const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' + +// Team path where notes will be created +const TEAM_PATH = 'DevOpsDay' + +// Conference details +const CONFERENCE_CONFIG = { + name: 'DevOpsDays Taipei 2025', + website: 'https://devopsdays.tw/', + community: 'https://www.facebook.com/groups/DevOpsTaiwan/', + tags: 'DevOpsDays Taipei 2025' +} +``` + +#### Session Data Format + +The script expects session data in `sessions.json` with the following structure: + +```json +[ + { + "id": "session-001", + "title": "Session Title", + "speaker": [ + { + "speaker": { + "public_name": "Speaker Name" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-15T09:00:00Z", + "finished_at": "2025-03-15T09:30:00Z", + "tags": ["tag1", "tag2"], + "classroom": { + "tw_name": "會議室名稱", + "en_name": "Room Name" + }, + "language": "en", + "difficulty": "General" + } +] +``` + +## Running the Example + +### Development Mode (with file watching) +```bash +npm run dev +``` + +### Production Mode +```bash +npm start +``` + +### Direct Execution with tsx +```bash +npx tsx index.ts +``` + +## Sample Session Data + +The included `sessions.json` contains sample conference session data with: + +- **Multiple session types**: keynotes, talks, workshops +- **Multi-day schedule**: Sessions across different days +- **Bilingual support**: English and Traditional Chinese sessions +- **Various difficulty levels**: General, Beginner, Intermediate, Advanced +- **Multiple speakers**: Examples of single and multiple speaker sessions + +## Generated Output + +The script will create: + +1. **Individual Session Notes**: Each with a dedicated HackMD note containing: + - Session title with speaker names + - Embedded announcement note + - Sections for collaborative note-taking + - Discussion area + - Related links + +2. **Main Conference Book**: A master note containing: + - Conference welcome information + - Organized schedule with links to all session notes + - Quick navigation by day and time + - Useful conference resources + +### Example Output + +``` +=== Creating Individual Session Notes === +✓ Created note for: Welcome to DevOpsDays - John Doe +✓ Created note for: Introduction to CI/CD - Jane Smith +✓ Created note for: Advanced Kubernetes Operations - Alex Chen & Sarah Wilson +... + +=== Session URLs === +[ + { + "id": "session-001", + "url": "https://hackmd.io/abc123", + "title": "Welcome to DevOpsDays - John Doe" + }, + ... +] + +=== Main Conference Book Created === +✓ Book URL: https://hackmd.io/xyz789 +🎉 Book mode conference notes created successfully! +📚 Main book contains links to 6 session notes +``` + +## Customization + +### Modifying Note Templates + +You can customize the session note template by modifying the `generateSessionNoteContent` function: + +```typescript +function generateSessionNoteContent(session: ProcessedSession): string { + return `# ${session.title} + +{%hackmd ${ANNOUNCEMENT_NOTE} %} + +## Your Custom Section +> Add your custom content here + +## ${SESSION_NOTE_CONFIG.sections.notes} +> ${SESSION_NOTE_CONFIG.sections.notesDescription} + +// ... rest of template +` +} +``` + +### Changing the Book Structure + +The book organization can be modified by changing the nesting keys in the main function: + +```typescript +// Current: organize by day, then by start time +const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) + +// Alternative: organize by session type, then by day +const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['sessionType', 'day']) +``` + +### Adding Additional Metadata + +You can extend the session data structure and processing by: + +1. Adding new fields to the `ProcessedSession` interface +2. Updating the `loadAndProcessSessions` function to process new fields +3. Modifying the note templates to include the new information + +## Error Handling + +The script includes comprehensive error handling: + +- **Missing Environment Variables**: Clear error messages with setup instructions +- **Missing Session File**: Helpful error message with expected file location +- **API Failures**: Individual session note failures don't stop the entire process +- **Network Issues**: The HackMD API client includes built-in retry logic + +## Troubleshooting + +### Common Issues + +**"HACKMD_ACCESS_TOKEN environment variable is not set"** +- Solution: Set your access token using one of the methods in the Setup section + +**"Sessions file not found"** +- Solution: Ensure `sessions.json` exists in the same directory as `index.ts` + +**"Failed to create note for [session]"** +- Check your team permissions +- Verify the team path is correct +- Ensure your access token has team note creation permissions + +**"Failed to create main book"** +- Same troubleshooting steps as individual notes +- Check that you have sufficient API quota remaining + +### Development Tips + +1. **Start Small**: Test with a few sessions first by modifying `sessions.json` +2. **Check Permissions**: Ensure your HackMD team allows note creation +3. **Monitor Rate Limits**: The script includes built-in retry logic, but be mindful of API limits +4. **Backup Data**: Consider backing up important notes before running the script + +## API Features Demonstrated + +This example showcases several HackMD API features: + +- **Team Note Creation**: Creating notes within a team context +- **Permission Management**: Setting read/write permissions for notes +- **Content Templates**: Using consistent note structures +- **Bulk Operations**: Creating multiple notes programmatically +- **Error Handling**: Graceful handling of API errors + +## License + +This example is part of the HackMD API client and is licensed under the MIT License. + +## Contributing + +If you have suggestions for improving this example or find bugs, please open an issue or submit a pull request to the main repository. \ No newline at end of file diff --git a/examples/book-mode-conference/index.ts b/examples/book-mode-conference/index.ts new file mode 100644 index 0000000..5c1b0e8 --- /dev/null +++ b/examples/book-mode-conference/index.ts @@ -0,0 +1,413 @@ +#!/usr/bin/env tsx +/** + * Book Mode Conference Note Generator + * + * This script generates a "book mode" conference note system using HackMD API. + * It creates individual notes for each session and a main book note that links to all sessions. + * + * Book mode is a Markdown note that contains organized links to each session note page, + * making it easy for conference attendees to navigate between different session notes. + * + * Prerequisites: + * - HackMD access token (set in HACKMD_ACCESS_TOKEN environment variable) + * - Team path where notes will be created + * - Session data in JSON format + */ + +'use strict' + +// Load environment variables from .env file in project root +import dotenv from 'dotenv' +dotenv.config() + +import _ from 'lodash' +import moment from 'moment' +import { API } from '@hackmd/api' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +// Get the current directory for ES modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// ========================================== +// CONFIGURATION CONSTANTS +// ========================================== + +/** + * HackMD announcement note short ID to be embedded in each session note + * This note typically contains conference-wide announcements or information + */ +const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' + +/** + * Team path where all notes will be created + * This should be your HackMD team's unique identifier + */ +const TEAM_PATH = 'DevOpsDay' + +/** + * Conference details for the main book note + */ +const CONFERENCE_CONFIG = { + name: 'DevOpsDays Taipei 2025', + website: 'https://devopsdays.tw/', + community: 'https://www.facebook.com/groups/DevOpsTaiwan/', + tags: 'DevOpsDays Taipei 2025' +} + +/** + * Session note template configuration + */ +const SESSION_NOTE_CONFIG = { + // Default content sections for each session note + sections: { + notes: '筆記區', + notesDescription: '從這開始記錄你的筆記', + discussion: '討論區', + discussionDescription: '歡迎在此進行討論', + links: '相關連結' + } +} + +/** + * Main book note configuration + */ +const BOOK_NOTE_CONFIG = { + welcomeNote: '/@DevOpsDay/ry9DnJIfel', + hackmdQuickStart: 'https://hackmd.io/s/BJvtP4zGX', + hackmdMeetingFeatures: 'https://hackmd.io/s/BJHWlNQMX' +} + +// ========================================== +// TYPE DEFINITIONS +// ========================================== + +/** + * Define permission constants (equivalent to the API enums) + * These mirror the NotePermissionRole enum from the API + */ +const NotePermissionRole = { + OWNER: 'owner', + SIGNED_IN: 'signed_in', + GUEST: 'guest' +} as const + +type NotePermissionRoleType = typeof NotePermissionRole[keyof typeof NotePermissionRole] + +/** + * Raw session data structure from JSON file + */ +interface RawSession { + id: string + title: string + speaker: Array<{ + speaker: { + public_name: string + } + }> + session_type: string | null + started_at: string + finished_at: string + tags?: string[] + classroom?: { + tw_name?: string + en_name?: string + } + language?: string + difficulty?: string +} + +/** + * Processed session data structure + */ +interface ProcessedSession { + id: string + title: string + tags: string[] + startDate: number + day: string + startTime: string + endTime: string + sessionType: string + classroom: string + language: string + difficulty: string + noteUrl?: string +} + +/** + * Session URL reference for output + */ +interface SessionUrl { + id: string + url: string + title: string +} + +// ========================================== +// UTILITY FUNCTIONS +// ========================================== + +/** + * Creates a nested object structure from an array using specified keys + * This is used to organize sessions by day and time for the book structure + * + * @param seq - Array of items to nest + * @param keys - Array of property names to use for nesting levels + * @returns Nested object structure + */ +function nest(seq: any[], keys: string[]): any { + if (!keys.length) return seq + + const [first, ...rest] = keys + return _.mapValues(_.groupBy(seq, first), function (value) { + return nest(value, rest) + }) +} + +/** + * Extracts the HackMD host URL from the API endpoint + * This is used to generate correct note URLs for display + * + * @returns The HackMD host URL + */ +function getHackMDHost(): string { + const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' + try { + const url = new URL(apiEndpoint) + return `${url.protocol}//${url.host}` + } catch (error) { + console.warn('Failed to parse HACKMD_API_ENDPOINT, falling back to https://hackmd.io') + return 'https://hackmd.io' + } +} + +/** + * Loads and processes session data from JSON file + * Filters out sessions with null session types and enriches data + * + * @returns Array of processed session data + */ +function loadAndProcessSessions(): ProcessedSession[] { + const sessionsPath = path.join(__dirname, 'sessions.json') + + if (!fs.existsSync(sessionsPath)) { + throw new Error(`Sessions file not found: ${sessionsPath}`) + } + + const rawSessions: RawSession[] = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')) + + return rawSessions + .filter(s => s.session_type && s.session_type !== null) // Filter out null session types + .map(s => { + // Combine speaker names with ampersand separator + const speakers = s.speaker.map(speaker => { + return speaker.speaker.public_name + }).join(' & ') + + return { + id: s.id, + title: s.title + (speakers ? " - " + speakers : ""), + tags: s.tags || [], + startDate: moment(s.started_at).valueOf(), + day: moment(s.started_at).format('MM/DD'), + startTime: moment(s.started_at).format('HH:mm'), + endTime: moment(s.finished_at).format('HH:mm'), + sessionType: s.session_type!, // We already filtered out null values above + classroom: s.classroom?.tw_name || s.classroom?.en_name || 'TBD', + language: s.language || 'en', + difficulty: s.difficulty || 'General' + } + }) + .sort((a, b) => (a.startDate - b.startDate)) // Sort by start time +} + +/** + * Generates the content for a session note + * + * @param session - The session data + * @returns Formatted markdown content for the session note + */ +function generateSessionNoteContent(session: ProcessedSession): string { + return `# ${session.title} + +{%hackmd ${ANNOUNCEMENT_NOTE} %} + +## ${SESSION_NOTE_CONFIG.sections.notes} +> ${SESSION_NOTE_CONFIG.sections.notesDescription} + +## ${SESSION_NOTE_CONFIG.sections.discussion} +> ${SESSION_NOTE_CONFIG.sections.discussionDescription} + +## ${SESSION_NOTE_CONFIG.sections.links} +- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) + +###### tags: \`${CONFERENCE_CONFIG.tags}\` +` +} + +/** + * Generates the hierarchical book content from nested session data + * + * @param sessions - Nested session data organized by day/time + * @param layer - Current nesting level (for header depth) + * @returns Formatted markdown content for the book section + */ +function generateBookContent(sessions: any, layer: number): string { + const days = Object.keys(sessions).sort() + let content = "" + + if (Array.isArray(sessions[days[0]])) { + // This is the leaf level (sessions) - flatten all sessions and sort chronologically + let allSessions: ProcessedSession[] = [] + for (let timeSlot of days) { + allSessions = allSessions.concat(sessions[timeSlot]) + } + // Sort all sessions by start time + const sortedSessions = _.sortBy(allSessions, ['startTime']) + + for (let session of sortedSessions) { + if (session.noteUrl && session.noteUrl !== 'error') { + content += `- ${session.startTime} ~ ${session.endTime} [${session.title}](/${session.noteUrl}) (${session.classroom})\n` + } + } + return content + } else { + // This is a grouping level + for (let day of days) { + content += `${new Array(layer).fill("#").join("")} ${day}\n\n` + content += generateBookContent(sessions[day], layer + 1) + } + return content + } +} + +/** + * Generates the main conference book note content + * + * @param bookContent - The hierarchical session content + * @returns Formatted markdown content for the main book note + */ +function generateMainBookContent(bookContent: string): string { + return `${CONFERENCE_CONFIG.name} 共同筆記 +=== + +## 歡迎來到 ${CONFERENCE_CONFIG.name}! + +- [歡迎來到 DevOpsDays!](${BOOK_NOTE_CONFIG.welcomeNote}) +- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) [target=_blank] +- [HackMD 快速入門](${BOOK_NOTE_CONFIG.hackmdQuickStart}) +- [HackMD 會議功能介紹](${BOOK_NOTE_CONFIG.hackmdMeetingFeatures}) + +## 議程筆記 + +${bookContent} + +## 相關資源 + +- [DevOps Taiwan Community](${CONFERENCE_CONFIG.community}) +- [活動照片分享區](#) +- [問題回饋](#) + +###### tags: \`${CONFERENCE_CONFIG.tags}\` +` +} + +// ========================================== +// MAIN EXECUTION LOGIC +// ========================================== + +/** + * Main function that orchestrates the entire book mode note creation process + */ +async function main(): Promise { + // Validate required environment variables + if (!process.env.HACKMD_ACCESS_TOKEN) { + console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.') + console.error('Please set your HackMD access token using one of these methods:') + console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here') + console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here') + process.exit(1) + } + + // Initialize API client + const api = new API(process.env.HACKMD_ACCESS_TOKEN, process.env.HACKMD_API_ENDPOINT) + + // Load and process session data + console.log('Loading session data...') + const sessionList = loadAndProcessSessions() + console.log(`Processing ${sessionList.length} sessions...`) + + // Create individual session notes + console.log('\n=== Creating Individual Session Notes ===') + for (let data of sessionList) { + const noteContent = generateSessionNoteContent(data) + + const noteData = { + title: data.title, + content: noteContent, + readPermission: NotePermissionRole.GUEST as any, + writePermission: NotePermissionRole.SIGNED_IN as any + } + + try { + const note = await api.createTeamNote(TEAM_PATH, noteData) + data.noteUrl = note.shortId + console.log(`✓ Created note for: ${data.title}`) + } catch (error: any) { + console.error(`✗ Failed to create note for ${data.title}:`, error.message) + data.noteUrl = 'error' + } + } + + // Output session URLs for reference + const hackmdHost = getHackMDHost() + const sessionUrls: SessionUrl[] = sessionList + .filter(s => s.noteUrl !== 'error') + .map(s => ({ + id: s.id, + url: `${hackmdHost}/${s.noteUrl}`, + title: s.title + })) + + console.log('\n=== Session URLs ===') + console.log(JSON.stringify(sessionUrls, null, 2)) + + // Create nested structure for the main book + const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) + const bookContent = generateBookContent(nestedSessions, 1) + + // Create main conference book + console.log('\n=== Creating Main Conference Book ===') + const mainBookContent = generateMainBookContent(bookContent) + + try { + const mainBook = await api.createTeamNote(TEAM_PATH, { + title: `${CONFERENCE_CONFIG.name} 共同筆記`, + content: mainBookContent, + readPermission: NotePermissionRole.GUEST as any, + writePermission: NotePermissionRole.SIGNED_IN as any + }) + + console.log('\n=== Main Conference Book Created ===') + console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) + console.log('\n🎉 Book mode conference notes created successfully!') + console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) + } catch (error: any) { + console.error('✗ Failed to create main book:', error.message) + } +} + +// ========================================== +// SCRIPT EXECUTION +// ========================================== + +// Run the script when executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error) +} + +// Export functions for potential module usage +export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } \ No newline at end of file diff --git a/examples/book-mode-conference/package-lock.json b/examples/book-mode-conference/package-lock.json new file mode 100644 index 0000000..839b6fc --- /dev/null +++ b/examples/book-mode-conference/package-lock.json @@ -0,0 +1,646 @@ +{ + "name": "hackmd-api-book-mode-conference-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hackmd-api-book-mode-conference-example", + "version": "1.0.0", + "dependencies": { + "@hackmd/api": "file:../../nodejs", + "dotenv": "^16.4.5", + "lodash": "^4.17.21", + "moment": "^2.29.4" + }, + "devDependencies": { + "@types/lodash": "^4.14.202", + "@types/node": "^20.10.6", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } + }, + "../../nodejs": { + "name": "@hackmd/api", + "version": "2.4.0", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "tslib": "^1.14.1" + }, + "devDependencies": { + "@faker-js/faker": "^7.6.0", + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/eslint": "^8.21.0", + "@types/jest": "^29.4.0", + "@types/node": "^13.11.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "dotenv": "^16.0.3", + "eslint": "^8.57.1", + "jest": "^29.4.2", + "msw": "^2.7.3", + "rimraf": "^4.1.2", + "rollup": "^4.41.1", + "ts-jest": "^29.0.5", + "ts-node": "^8.8.2", + "typescript": "^4.9.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hackmd/api": { + "resolved": "../../nodejs", + "link": true + }, + "node_modules/@types/lodash": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", + "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", + "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/book-mode-conference/package.json b/examples/book-mode-conference/package.json new file mode 100644 index 0000000..6debafc --- /dev/null +++ b/examples/book-mode-conference/package.json @@ -0,0 +1,23 @@ +{ + "name": "hackmd-api-book-mode-conference-example", + "version": "1.0.0", + "description": "Example for creating a book mode conference note with HackMD API", + "main": "index.ts", + "type": "module", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@hackmd/api": "file:../../nodejs", + "dotenv": "^16.4.5", + "lodash": "^4.17.21", + "moment": "^2.29.4" + }, + "devDependencies": { + "@types/lodash": "^4.14.202", + "@types/node": "^20.10.6", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/examples/book-mode-conference/sessions.json b/examples/book-mode-conference/sessions.json new file mode 100644 index 0000000..829ad2f --- /dev/null +++ b/examples/book-mode-conference/sessions.json @@ -0,0 +1,133 @@ +[ + { + "id": "session-001", + "title": "Welcome to DevOpsDays", + "speaker": [ + { + "speaker": { + "public_name": "John Doe" + } + } + ], + "session_type": "keynote", + "started_at": "2025-03-15T09:00:00Z", + "finished_at": "2025-03-15T09:30:00Z", + "tags": ["welcome", "keynote"], + "classroom": { + "tw_name": "主舞台", + "en_name": "Main Stage" + }, + "language": "en", + "difficulty": "General" + }, + { + "id": "session-002", + "title": "Introduction to CI/CD", + "speaker": [ + { + "speaker": { + "public_name": "Jane Smith" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-15T10:00:00Z", + "finished_at": "2025-03-15T10:45:00Z", + "tags": ["ci", "cd", "automation"], + "classroom": { + "tw_name": "A會議室", + "en_name": "Room A" + }, + "language": "en", + "difficulty": "Beginner" + }, + { + "id": "session-003", + "title": "Advanced Kubernetes Operations", + "speaker": [ + { + "speaker": { + "public_name": "Alex Chen" + } + }, + { + "speaker": { + "public_name": "Sarah Wilson" + } + } + ], + "session_type": "workshop", + "started_at": "2025-03-15T11:00:00Z", + "finished_at": "2025-03-15T12:00:00Z", + "tags": ["kubernetes", "containers", "orchestration"], + "classroom": { + "tw_name": "B會議室", + "en_name": "Room B" + }, + "language": "en", + "difficulty": "Advanced" + }, + { + "id": "session-004", + "title": "DevOps Culture and Practices", + "speaker": [ + { + "speaker": { + "public_name": "Mike Johnson" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-15T14:00:00Z", + "finished_at": "2025-03-15T14:45:00Z", + "tags": ["culture", "practices", "team"], + "classroom": { + "tw_name": "主舞台", + "en_name": "Main Stage" + }, + "language": "en", + "difficulty": "General" + }, + { + "id": "session-005", + "title": "監控與可觀測性", + "speaker": [ + { + "speaker": { + "public_name": "林小明" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-16T09:30:00Z", + "finished_at": "2025-03-16T10:15:00Z", + "tags": ["monitoring", "observability"], + "classroom": { + "tw_name": "A會議室", + "en_name": "Room A" + }, + "language": "zh-TW", + "difficulty": "Intermediate" + }, + { + "id": "session-006", + "title": "Security in DevOps Pipeline", + "speaker": [ + { + "speaker": { + "public_name": "Emma Davis" + } + } + ], + "session_type": "workshop", + "started_at": "2025-03-16T10:30:00Z", + "finished_at": "2025-03-16T12:00:00Z", + "tags": ["security", "devsecops", "pipeline"], + "classroom": { + "tw_name": "C會議室", + "en_name": "Room C" + }, + "language": "en", + "difficulty": "Intermediate" + } +] \ No newline at end of file diff --git a/examples/book-mode-conference/tsconfig.json b/examples/book-mode-conference/tsconfig.json new file mode 100644 index 0000000..2301721 --- /dev/null +++ b/examples/book-mode-conference/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./", + "resolveJsonModule": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file From 51d87d7cbae22d6c083ce598a755b3f0fae931e5 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Sat, 9 Aug 2025 10:52:39 +0800 Subject: [PATCH 07/13] examples(book-mode-conference): add progress/resume support - Introduce a simple progress manager inside the example - Support RESUME_MODE env or --resume flag to continue - Persist per-session note creation and restore URLs on resume - Save progress after each note; include book URL on success --- examples/book-mode-conference/index.ts | 82 ++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/examples/book-mode-conference/index.ts b/examples/book-mode-conference/index.ts index 5c1b0e8..e113e3c 100644 --- a/examples/book-mode-conference/index.ts +++ b/examples/book-mode-conference/index.ts @@ -319,6 +319,57 @@ ${bookContent} // MAIN EXECUTION LOGIC // ========================================== +// Simple reusable progress manager +type ProgressState = { + completedSessions: string[] + sessionNotes: Record + mainBookCreated?: boolean + mainBookUrl?: string + startedAt?: string + completedAt?: string +} + +function createProgressManager(progressFilePath: string) { + const resolvedPath = path.resolve(progressFilePath) + + function load(): ProgressState | null { + if (!fs.existsSync(resolvedPath)) return null + try { + const data = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + return data + } catch (e: any) { + console.warn(`⚠️ Failed to load progress: ${e.message}`) + return null + } + } + + function initFresh(): ProgressState { + if (fs.existsSync(resolvedPath)) { + try { fs.unlinkSync(resolvedPath) } catch {} + } + return { + completedSessions: [], + sessionNotes: {}, + startedAt: new Date().toISOString(), + } + } + + function save(progress: ProgressState) { + try { fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) } catch {} + } + + function isSessionDone(id: string, p: ProgressState) { + return p.completedSessions.includes(id) + } + + function markSessionDone(id: string, noteUrl: string, p: ProgressState) { + if (!p.completedSessions.includes(id)) p.completedSessions.push(id) + p.sessionNotes[id] = noteUrl + } + + return { load, initFresh, save, isSessionDone, markSessionDone, progressFilePath: resolvedPath } +} + /** * Main function that orchestrates the entire book mode note creation process */ @@ -340,9 +391,32 @@ async function main(): Promise { const sessionList = loadAndProcessSessions() console.log(`Processing ${sessionList.length} sessions...`) + // Progress/resume support + const pm = createProgressManager(path.join(__dirname, 'progress.json')) + const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') + let progress: ProgressState | null = null + if (RESUME_MODE) { + progress = pm.load() + if (!progress) { + console.error('No progress.json found. Start without --resume to create it.') + process.exit(1) + } + console.log(`🔄 Resume mode: ${progress.completedSessions.length} sessions already created`) + } else { + progress = pm.initFresh() + console.log('🚀 Fresh run: progress initialized') + } + // Create individual session notes console.log('\n=== Creating Individual Session Notes ===') for (let data of sessionList) { + if (pm.isSessionDone(data.id, progress!)) { + // restore URL + if (progress!.sessionNotes[data.id]) data.noteUrl = progress!.sessionNotes[data.id].replace(`${getHackMDHost()}/`, '') + console.log(`⏭️ Skip existing: ${data.title}`) + continue + } + const noteContent = generateSessionNoteContent(data) const noteData = { @@ -355,6 +429,8 @@ async function main(): Promise { try { const note = await api.createTeamNote(TEAM_PATH, noteData) data.noteUrl = note.shortId + pm.markSessionDone(data.id, `${getHackMDHost()}/${note.shortId}`, progress!) + pm.save(progress!) console.log(`✓ Created note for: ${data.title}`) } catch (error: any) { console.error(`✗ Failed to create note for ${data.title}:`, error.message) @@ -395,6 +471,12 @@ async function main(): Promise { console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) console.log('\n🎉 Book mode conference notes created successfully!') console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) + if (progress) { + progress.mainBookCreated = true + progress.mainBookUrl = `${hackmdHost}/${mainBook.shortId}` + progress.completedAt = new Date().toISOString() + pm.save(progress) + } } catch (error: any) { console.error('✗ Failed to create main book:', error.message) } From 002948c757e4612537dcdbe6427477e78b5dc4e7 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Wed, 13 Aug 2025 09:33:18 +0900 Subject: [PATCH 08/13] feat: update the example --- examples/book-mode-conference/.env.example | 51 +- examples/book-mode-conference/README.md | 351 ++++++------ examples/book-mode-conference/index.ts | 532 ++++++++++++------ .../book-mode-conference/package-lock.json | 2 +- examples/book-mode-conference/sessions.json | 24 +- 5 files changed, 569 insertions(+), 391 deletions(-) diff --git a/examples/book-mode-conference/.env.example b/examples/book-mode-conference/.env.example index 5b7704b..ec9cee7 100644 --- a/examples/book-mode-conference/.env.example +++ b/examples/book-mode-conference/.env.example @@ -1,8 +1,49 @@ -# HackMD API Configuration -# Get your access token from: https://hackmd.io/@hackmd-api/developer-portal +# HackMD Conference Note Generation Environment Variables -# Required: Your HackMD access token +# Required: HackMD API Access Token +# Get this from your HackMD instance settings > API tokens +# For hackmd.io: https://hackmd.io/@hackmd-api/developer-portal HACKMD_ACCESS_TOKEN=your_access_token_here -# Optional: HackMD API endpoint (defaults to https://api.hackmd.io/v1) -HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 \ No newline at end of file +# Required: HackMD API Endpoint URL +# For hackmd.io: https://api.hackmd.io/v1 +# For self-hosted: https://your-hackmd-instance.com/api/v1 +HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 + +# Optional: HackMD Web Domain (for generating correct note URLs) +# This is useful when your API endpoint differs from the web domain +# For hackmd.io: https://hackmd.io +# For self-hosted: https://your-hackmd-instance.com +# If not set, defaults to the API endpoint +HACKMD_WEB_DOMAIN=https://hackmd.io + +# Optional: Test Mode +# Set to 'true' to create limited notes for testing +# Set to 'false' or omit for full note generation +TEST_MODE=false + +# Optional: Resume Mode +# Set to 'true' to resume from previous interrupted execution +# Set to 'false' or omit for fresh generation +RESUME_MODE=false + +# Optional: Fixed delay (milliseconds) between API requests +# Use to avoid rate limits in production environments +# Can also be set via --delay-ms CLI flag +# Recommended: 200-500ms for production +REQUEST_DELAY_MS=0 + +# Example configurations: +# +# For hackmd.io: +# HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 +# HACKMD_WEB_DOMAIN=https://hackmd.io +# +# For self-hosted HackMD: +# HACKMD_API_ENDPOINT=https://your-hackmd.example.com/api/v1 +# HACKMD_WEB_DOMAIN=https://your-hackmd.example.com +# +# Production environment example: +# TEST_MODE=false +# REQUEST_DELAY_MS=300 +# RESUME_MODE=false \ No newline at end of file diff --git a/examples/book-mode-conference/README.md b/examples/book-mode-conference/README.md index 5b5f20b..707a6b2 100644 --- a/examples/book-mode-conference/README.md +++ b/examples/book-mode-conference/README.md @@ -1,120 +1,84 @@ # Book Mode Conference Note Generator -This example demonstrates how to create a "book mode" conference note system using the HackMD API. Book mode is a Markdown note that contains organized links to each session note page, making it easy for conference attendees to navigate between different session notes. +This example demonstrates how to create a "book mode" conference note system using the HackMD API with resume functionality for production environments. ## What This Example Does -The script performs the following actions: - -1. **Loads Session Data**: Reads conference session information from `sessions.json` -2. **Creates Individual Session Notes**: For each session, creates a dedicated HackMD note with: +1. **Creates Individual Session Notes**: One note per session with: - Session title and speaker information + - Time, room, and session details - Embedded announcement note - - Sections for notes, discussion, and related links - - Appropriate tags and permissions -3. **Generates Main Book Note**: Creates a master note that: - - Contains welcome information and useful links - - Organizes all session notes by day and time - - Provides easy navigation to all sessions - - Serves as a central hub for the conference - -## Features - -- **TypeScript Implementation**: Written in TypeScript with full type safety -- **Configurable Constants**: All configuration is centralized at the top of the file -- **Comprehensive Comments**: Well-documented code explaining each section -- **Error Handling**: Graceful handling of API failures -- **tsx Support**: Can be run directly without compilation using tsx -- **Modular Design**: Functions are exportable for potential reuse -- **Flexible Session Data**: Supports various session types and multilingual content - -## Setup - -### Prerequisites - -- Node.js (version 16 or higher) -- A HackMD account with API access -- Access to a HackMD team (for creating team notes) - -### Installation + - Sections for notes, Q&A, and discussion -1. **Build the main HackMD API package** (if not already done): - ```bash - cd ../../nodejs - npm install - npm run build - cd ../examples/book-mode-conference - ``` +2. **Creates Main Book Note**: A master index that: + - Lists all session notes organized by day and time + - Provides easy navigation between sessions + - Serves as the conference note hub -2. **Install dependencies**: - ```bash - npm install - ``` +3. **Resume Functionality**: + - Saves progress automatically + - Can resume if interrupted (power outage, network issues, etc.) + - Tracks completed sessions to avoid duplicates -3. **Configure your HackMD access token**: - - **Option A: Environment Variable** - ```bash - # For Unix/Linux/macOS - export HACKMD_ACCESS_TOKEN=your_access_token_here - - # For Windows PowerShell - $env:HACKMD_ACCESS_TOKEN="your_access_token_here" - ``` +## Setup - **Option B: .env File** - ```bash - cp .env.example .env - # Edit .env and add your access token - ``` +### 1. Install Dependencies +```bash +cd /path/to/api-client/examples/book-mode-conference +npm install +``` - You can get your access token from the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). +### 2. Configure Environment +```bash +cp .env.example .env +# Edit .env with your settings +``` -### Configuration +Required `.env` settings: +```bash +HACKMD_ACCESS_TOKEN=your_access_token_here +HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 +HACKMD_WEB_DOMAIN=https://hackmd.io +``` -Before running the script, you may want to customize the configuration constants at the top of `index.ts`: +### 3. Customize Configuration -#### Essential Configuration +Edit the constants at the top of `index.ts`: ```typescript // HackMD announcement note to embed in each session note -const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' // Team path where notes will be created -const TEAM_PATH = 'DevOpsDay' - -// Conference details -const CONFERENCE_CONFIG = { - name: 'DevOpsDays Taipei 2025', - website: 'https://devopsdays.tw/', - community: 'https://www.facebook.com/groups/DevOpsTaiwan/', - tags: 'DevOpsDays Taipei 2025' -} +const TEAM_PATH = 'TechConf' + +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' ``` -#### Session Data Format +### 4. Prepare Session Data -The script expects session data in `sessions.json` with the following structure: +Ensure `sessions.json` exists with your conference session data: ```json [ { "id": "session-001", - "title": "Session Title", + "title": "Opening Keynote: The Future of Technology", "speaker": [ { "speaker": { - "public_name": "Speaker Name" + "public_name": "John Doe" } } ], - "session_type": "talk", + "session_type": "keynote", "started_at": "2025-03-15T09:00:00Z", "finished_at": "2025-03-15T09:30:00Z", - "tags": ["tag1", "tag2"], + "tags": ["welcome", "keynote"], "classroom": { - "tw_name": "會議室名稱", - "en_name": "Room Name" + "tw_name": "主舞台", + "en_name": "Main Stage" }, "language": "en", "difficulty": "General" @@ -122,167 +86,180 @@ The script expects session data in `sessions.json` with the following structure: ] ``` -## Running the Example +## Usage -### Development Mode (with file watching) +### Test Mode (Recommended First) ```bash -npm run dev +# Creates only 3 sessions for testing +npx tsx index.ts --test ``` ### Production Mode ```bash -npm start +# Create all session notes +npx tsx index.ts + +# With rate limiting (recommended for large conferences) +npx tsx index.ts --delay-ms 300 ``` -### Direct Execution with tsx +### Resume Interrupted Execution ```bash -npx tsx index.ts -``` +# If the script was interrupted, resume from where it left off +npx tsx index.ts --resume -## Sample Session Data +# Resume with rate limiting +npx tsx index.ts --resume --delay-ms 500 +``` -The included `sessions.json` contains sample conference session data with: +### All Available Options +```bash +npx tsx index.ts [options] -- **Multiple session types**: keynotes, talks, workshops -- **Multi-day schedule**: Sessions across different days -- **Bilingual support**: English and Traditional Chinese sessions -- **Various difficulty levels**: General, Beginner, Intermediate, Advanced -- **Multiple speakers**: Examples of single and multiple speaker sessions +Options: + --test Test mode - create only first 3 sessions + --resume Resume from previous interrupted execution + --delay-ms Add delay (ms) between API requests + --help, -h Show help message +``` ## Generated Output -The script will create: +### Session Notes +Each session gets a note with this structure: +```markdown +# Session Title - Speaker Name -1. **Individual Session Notes**: Each with a dedicated HackMD note containing: - - Session title with speaker names - - Embedded announcement note - - Sections for collaborative note-taking - - Discussion area - - Related links +**Time:** 09:00 ~ 09:30 | **Room:** Main Stage -2. **Main Conference Book**: A master note containing: - - Conference welcome information - - Organized schedule with links to all session notes - - Quick navigation by day and time - - Useful conference resources +{%hackmd @TechConf/announcement-note-id %} -### Example Output +> ==投影片== +> (講者請在此放置投影片連結) -``` -=== Creating Individual Session Notes === -✓ Created note for: Welcome to DevOpsDays - John Doe -✓ Created note for: Introduction to CI/CD - Jane Smith -✓ Created note for: Advanced Kubernetes Operations - Alex Chen & Sarah Wilson -... +> ==Q & A== +> (講者 Q&A 相關連結) -=== Session URLs === -[ - { - "id": "session-001", - "url": "https://hackmd.io/abc123", - "title": "Welcome to DevOpsDays - John Doe" - }, - ... -] +## 📝 筆記區 +> 請從這裡開始記錄你的筆記 -=== Main Conference Book Created === -✓ Book URL: https://hackmd.io/xyz789 -🎉 Book mode conference notes created successfully! -📚 Main book contains links to 6 session notes +## ❓ Q&A 區域 +> 講者問答與現場互動 + +## 💬 討論區 +> 歡迎在此進行討論與交流 ``` -## Customization +### Main Book Note +The index book organizes sessions by day: +```markdown +TechConf 2025 共同筆記 +=== -### Modifying Note Templates +## 歡迎來到 TechConf 2025! -You can customize the session note template by modifying the `generateSessionNoteContent` function: +- [HackMD 快速入門](https://hackmd.io/s/BJvtP4zGX) +- [HackMD 會議功能介紹](https://hackmd.io/s/BJHWlNQMX) -```typescript -function generateSessionNoteContent(session: ProcessedSession): string { - return `# ${session.title} +## 議程筆記 -{%hackmd ${ANNOUNCEMENT_NOTE} %} +### 03/15 +- 09:00 ~ 09:30 [Opening Keynote: The Future of Technology - John Doe](/session-note-id) (Main Stage) +- 10:00 ~ 10:45 [Advanced Cloud Architecture - Jane Smith](/session-note-id) (Room A) +``` -## Your Custom Section -> Add your custom content here +## Resume Functionality -## ${SESSION_NOTE_CONFIG.sections.notes} -> ${SESSION_NOTE_CONFIG.sections.notesDescription} +The script automatically saves progress to `progress.json`: -// ... rest of template -` +```json +{ + "completedSessions": ["session-001", "session-002"], + "sessionNotes": { + "session-001": "https://hackmd.io/abc123", + "session-002": "https://hackmd.io/def456" + }, + "mainBookCreated": false, + "startedAt": "2025-01-15T10:00:00.000Z" } ``` -### Changing the Book Structure +### When to Use Resume -The book organization can be modified by changing the nesting keys in the main function: +Use `--resume` when: +- Script was interrupted (network issues, power outage, etc.) +- Hit API rate limits and need to continue later +- Want to add new sessions to existing conference notes -```typescript -// Current: organize by day, then by start time -const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) +### Resume Workflow + +```bash +# 1. Start generation +npx tsx index.ts --delay-ms 300 -// Alternative: organize by session type, then by day -const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['sessionType', 'day']) +# 2. Script fails after 50 sessions (network issue) +# 3. Wait a few minutes for rate limits to reset +# 4. Resume from session 51 +npx tsx index.ts --resume --delay-ms 400 ``` -### Adding Additional Metadata +## Troubleshooting -You can extend the session data structure and processing by: +### Environment Variable Issues -1. Adding new fields to the `ProcessedSession` interface -2. Updating the `loadAndProcessSessions` function to process new fields -3. Modifying the note templates to include the new information +Test if your `.env` file is loaded correctly: +```bash +node test-env.js +``` -## Error Handling +### Common Errors -The script includes comprehensive error handling: +**401 Authentication Error** +- Check `HACKMD_ACCESS_TOKEN` is correct +- Verify token has team permissions +- Ensure API endpoint is correct -- **Missing Environment Variables**: Clear error messages with setup instructions -- **Missing Session File**: Helpful error message with expected file location -- **API Failures**: Individual session note failures don't stop the entire process -- **Network Issues**: The HackMD API client includes built-in retry logic +**"Session file not found"** +- Ensure `sessions.json` exists in same directory +- Check JSON format is valid -## Troubleshooting +**"Failed to create note"** +- Check team permissions +- Verify `TEAM_PATH` is correct +- Check API quota limits -### Common Issues +### Manual Override -**"HACKMD_ACCESS_TOKEN environment variable is not set"** -- Solution: Set your access token using one of the methods in the Setup section +If `.env` isn't working: +```bash +export HACKMD_ACCESS_TOKEN=your_token_here +npx tsx index.ts --test +``` -**"Sessions file not found"** -- Solution: Ensure `sessions.json` exists in the same directory as `index.ts` +## Customization -**"Failed to create note for [session]"** -- Check your team permissions -- Verify the team path is correct -- Ensure your access token has team note creation permissions +The example is designed to be easily customizable: -**"Failed to create main book"** -- Same troubleshooting steps as individual notes -- Check that you have sufficient API quota remaining +### Session Note Template +Edit `generateSessionNoteContent()` function to change note structure. -### Development Tips +### Book Organization +Edit `generateBookContent()` function to change how sessions are grouped. -1. **Start Small**: Test with a few sessions first by modifying `sessions.json` -2. **Check Permissions**: Ensure your HackMD team allows note creation -3. **Monitor Rate Limits**: The script includes built-in retry logic, but be mindful of API limits -4. **Backup Data**: Consider backing up important notes before running the script +### Excluded Sessions +Edit `EXCLUDE_SESSIONS` array to filter out non-content sessions. -## API Features Demonstrated +### Conference Details +Change `CONFERENCE_NAME`, `TEAM_PATH`, and `ANNOUNCEMENT_NOTE` constants. -This example showcases several HackMD API features: +## Production Tips -- **Team Note Creation**: Creating notes within a team context -- **Permission Management**: Setting read/write permissions for notes -- **Content Templates**: Using consistent note structures -- **Bulk Operations**: Creating multiple notes programmatically -- **Error Handling**: Graceful handling of API errors +1. **Always test first**: Use `--test` to verify configuration +2. **Use rate limiting**: Add `--delay-ms 300` for large conferences +3. **Monitor progress**: Keep `progress.json` until completion +4. **Plan for interruptions**: Use `--resume` if anything goes wrong +5. **Check permissions**: Ensure your token can create team notes ## License -This example is part of the HackMD API client and is licensed under the MIT License. - -## Contributing - -If you have suggestions for improving this example or find bugs, please open an issue or submit a pull request to the main repository. \ No newline at end of file +This example is part of the HackMD API client and is licensed under the MIT License. \ No newline at end of file diff --git a/examples/book-mode-conference/index.ts b/examples/book-mode-conference/index.ts index e113e3c..579af29 100644 --- a/examples/book-mode-conference/index.ts +++ b/examples/book-mode-conference/index.ts @@ -1,13 +1,21 @@ #!/usr/bin/env tsx /** - * Book Mode Conference Note Generator - * - * This script generates a "book mode" conference note system using HackMD API. - * It creates individual notes for each session and a main book note that links to all sessions. - * - * Book mode is a Markdown note that contains organized links to each session note page, - * making it easy for conference attendees to navigate between different session notes. - * + * Production-Ready Book Mode Conference Note Generator + * + * This script generates a "book mode" conference note system using HackMD API with + * production-ready features including resume functionality, progress tracking, and + * comprehensive error handling. + * + * Based on proven patterns from large-scale conference implementations. + * + * Features: + * - Resume interrupted executions (--resume flag) + * - Progress tracking with automatic backups + * - Rate limiting and request delay controls + * - Comprehensive CLI help and configuration + * - Production-ready error handling + * - Test mode for safe development + * * Prerequisites: * - HackMD access token (set in HACKMD_ACCESS_TOKEN environment variable) * - Team path where notes will be created @@ -16,69 +24,106 @@ 'use strict' -// Load environment variables from .env file in project root +// Load environment variables from .env file import dotenv from 'dotenv' -dotenv.config() +import { fileURLToPath } from 'url' +import path from 'path' + +// Get the current directory for ES modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Load .env file from the same directory as this script +dotenv.config({ path: path.join(__dirname, '.env') }) import _ from 'lodash' import moment from 'moment' import { API } from '@hackmd/api' import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -// Get the current directory for ES modules -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) +// ========================================== +// CLI HELP AND ARGUMENT PARSING +// ========================================== + +if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(` +🎯 Production-Ready Conference Note Generator + +Usage: npx tsx index.ts [options] + +Options: + --test Test mode - create only first 3 sessions + --resume Resume from previous interrupted execution + --delay-ms Add a fixed delay (ms) between API requests + --help, -h Show this help message + +Environment Variables: + TEST_MODE=true|false Same as --test + RESUME_MODE=true|false Same as --resume + REQUEST_DELAY_MS=number Same as --delay-ms + HACKMD_ACCESS_TOKEN=token HackMD API token (required) + HACKMD_API_ENDPOINT=url HackMD API endpoint (optional) + HACKMD_WEB_DOMAIN=url HackMD web domain (optional) + +Resume Feature (Production Critical): + If the script fails during execution, it saves progress to progress.json. + Use --resume to continue from where it left off. + + Example production workflow: + 1. npx tsx index.ts --delay-ms 500 # Start with 500ms delay + 2. Script fails after 50 notes # Due to limits or network issues + 3. Wait 5-10 minutes # Let rate limits reset + 4. npx tsx index.ts --resume # Continue from note 51 + +Production Tips: + - Always use --delay-ms in production (recommend 200-500ms) + - Monitor API rate limits and adjust delays accordingly + - Keep progress.json file until completion for recovery + - Use --test first to validate configuration + +Examples: + npx tsx index.ts --test # Test with 3 sessions + npx tsx index.ts --delay-ms 300 # Production run with 300ms delay + npx tsx index.ts --resume --delay-ms 500 # Resume with 500ms delay +`) + process.exit(0) +} + +// Parse CLI arguments +const TEST_MODE = process.env.TEST_MODE === 'true' || process.argv.includes('--test') +const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') +const PROGRESS_FILE = path.join(__dirname, 'progress.json') + +// Parse request delay +const ENV_REQUEST_DELAY_MS = parseInt(process.env.REQUEST_DELAY_MS || '0', 10) +let CLI_REQUEST_DELAY_MS = ENV_REQUEST_DELAY_MS +const delayFlagIndex = process.argv.indexOf('--delay-ms') +if (delayFlagIndex !== -1 && process.argv[delayFlagIndex + 1]) { + const parsed = parseInt(process.argv[delayFlagIndex + 1], 10) + if (!Number.isNaN(parsed)) CLI_REQUEST_DELAY_MS = parsed +} // ========================================== // CONFIGURATION CONSTANTS // ========================================== -/** - * HackMD announcement note short ID to be embedded in each session note - * This note typically contains conference-wide announcements or information - */ -const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' +// ========================================== +// CONFIGURATION - CUSTOMIZE THESE VALUES +// ========================================== -/** - * Team path where all notes will be created - * This should be your HackMD team's unique identifier - */ -const TEAM_PATH = 'DevOpsDay' +// HackMD announcement note to embed in each session note +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' -/** - * Conference details for the main book note - */ -const CONFERENCE_CONFIG = { - name: 'DevOpsDays Taipei 2025', - website: 'https://devopsdays.tw/', - community: 'https://www.facebook.com/groups/DevOpsTaiwan/', - tags: 'DevOpsDays Taipei 2025' -} +// Team path where notes will be created +const TEAM_PATH = 'TechConf' -/** - * Session note template configuration - */ -const SESSION_NOTE_CONFIG = { - // Default content sections for each session note - sections: { - notes: '筆記區', - notesDescription: '從這開始記錄你的筆記', - discussion: '討論區', - discussionDescription: '歡迎在此進行討論', - links: '相關連結' - } -} +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' -/** - * Main book note configuration - */ -const BOOK_NOTE_CONFIG = { - welcomeNote: '/@DevOpsDay/ry9DnJIfel', - hackmdQuickStart: 'https://hackmd.io/s/BJvtP4zGX', - hackmdMeetingFeatures: 'https://hackmd.io/s/BJHWlNQMX' -} +// Sessions to exclude from note generation (customize as needed) +const EXCLUDE_SESSIONS = [ + '報到時間', '開幕', '閉幕', 'Opening', 'Closing', 'Break', 'Lunch', '休息時間', '午餐' +] // ========================================== // TYPE DEFINITIONS @@ -90,7 +135,7 @@ const BOOK_NOTE_CONFIG = { */ const NotePermissionRole = { OWNER: 'owner', - SIGNED_IN: 'signed_in', + SIGNED_IN: 'signed_in', GUEST: 'guest' } as const @@ -153,14 +198,14 @@ interface SessionUrl { /** * Creates a nested object structure from an array using specified keys * This is used to organize sessions by day and time for the book structure - * + * * @param seq - Array of items to nest * @param keys - Array of property names to use for nesting levels * @returns Nested object structure */ function nest(seq: any[], keys: string[]): any { if (!keys.length) return seq - + const [first, ...rest] = keys return _.mapValues(_.groupBy(seq, first), function (value) { return nest(value, rest) @@ -168,89 +213,81 @@ function nest(seq: any[], keys: string[]): any { } /** - * Extracts the HackMD host URL from the API endpoint - * This is used to generate correct note URLs for display - * - * @returns The HackMD host URL - */ -function getHackMDHost(): string { - const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' - try { - const url = new URL(apiEndpoint) - return `${url.protocol}//${url.host}` - } catch (error) { - console.warn('Failed to parse HACKMD_API_ENDPOINT, falling back to https://hackmd.io') - return 'https://hackmd.io' - } -} - -/** - * Loads and processes session data from JSON file - * Filters out sessions with null session types and enriches data - * - * @returns Array of processed session data + * Load and process session data from JSON file */ function loadAndProcessSessions(): ProcessedSession[] { const sessionsPath = path.join(__dirname, 'sessions.json') - + if (!fs.existsSync(sessionsPath)) { throw new Error(`Sessions file not found: ${sessionsPath}`) } - + const rawSessions: RawSession[] = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')) - + return rawSessions - .filter(s => s.session_type && s.session_type !== null) // Filter out null session types + .filter(s => { + if (!s.session_type) return false + const title = (s.title || '').trim() + return !EXCLUDE_SESSIONS.includes(title) + }) .map(s => { - // Combine speaker names with ampersand separator - const speakers = s.speaker.map(speaker => { - return speaker.speaker.public_name - }).join(' & ') + const speakers = s.speaker.map(speaker => speaker.speaker.public_name).join('、') return { id: s.id, - title: s.title + (speakers ? " - " + speakers : ""), - tags: s.tags || [], + title: s.title + (speakers ? ` - ${speakers}` : ""), + tags: [CONFERENCE_NAME, ...(s.tags || [])], startDate: moment(s.started_at).valueOf(), day: moment(s.started_at).format('MM/DD'), startTime: moment(s.started_at).format('HH:mm'), endTime: moment(s.finished_at).format('HH:mm'), - sessionType: s.session_type!, // We already filtered out null values above + sessionType: s.session_type, classroom: s.classroom?.tw_name || s.classroom?.en_name || 'TBD', language: s.language || 'en', difficulty: s.difficulty || 'General' } }) - .sort((a, b) => (a.startDate - b.startDate)) // Sort by start time + .sort((a, b) => (a.startDate - b.startDate)) } /** - * Generates the content for a session note - * - * @param session - The session data - * @returns Formatted markdown content for the session note + * Generate content for a session note */ function generateSessionNoteContent(session: ProcessedSession): string { return `# ${session.title} +**Time:** ${session.startTime} ~ ${session.endTime} | **Room:** ${session.classroom} + {%hackmd ${ANNOUNCEMENT_NOTE} %} -## ${SESSION_NOTE_CONFIG.sections.notes} -> ${SESSION_NOTE_CONFIG.sections.notesDescription} +> ==投影片== +> (講者請在此放置投影片連結) + +> ==Q & A== +> (講者 Q&A 相關連結) + +## 📝 筆記區 +> 請從這裡開始記錄你的筆記 + + + +## ❓ Q&A 區域 +> 講者問答與現場互動 + + -## ${SESSION_NOTE_CONFIG.sections.discussion} -> ${SESSION_NOTE_CONFIG.sections.discussionDescription} +## 💬 討論區 +> 歡迎在此進行討論與交流 -## ${SESSION_NOTE_CONFIG.sections.links} -- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) -###### tags: \`${CONFERENCE_CONFIG.tags}\` + +###### tags: \`${CONFERENCE_NAME}\` ` } /** * Generates the hierarchical book content from nested session data - * + * * @param sessions - Nested session data organized by day/time * @param layer - Current nesting level (for header depth) * @returns Formatted markdown content for the book section @@ -285,33 +322,22 @@ function generateBookContent(sessions: any, layer: number): string { } /** - * Generates the main conference book note content - * - * @param bookContent - The hierarchical session content - * @returns Formatted markdown content for the main book note + * Generate the main conference book content */ function generateMainBookContent(bookContent: string): string { - return `${CONFERENCE_CONFIG.name} 共同筆記 + return `${CONFERENCE_NAME} 共同筆記 === -## 歡迎來到 ${CONFERENCE_CONFIG.name}! +## 歡迎來到 ${CONFERENCE_NAME}! -- [歡迎來到 DevOpsDays!](${BOOK_NOTE_CONFIG.welcomeNote}) -- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) [target=_blank] -- [HackMD 快速入門](${BOOK_NOTE_CONFIG.hackmdQuickStart}) -- [HackMD 會議功能介紹](${BOOK_NOTE_CONFIG.hackmdMeetingFeatures}) +- [HackMD 快速入門](https://hackmd.io/s/BJvtP4zGX) +- [HackMD 會議功能介紹](https://hackmd.io/s/BJHWlNQMX) ## 議程筆記 ${bookContent} -## 相關資源 - -- [DevOps Taiwan Community](${CONFERENCE_CONFIG.community}) -- [活動照片分享區](#) -- [問題回饋](#) - -###### tags: \`${CONFERENCE_CONFIG.tags}\` +###### tags: \`${CONFERENCE_NAME}\` ` } @@ -319,7 +345,7 @@ ${bookContent} // MAIN EXECUTION LOGIC // ========================================== -// Simple reusable progress manager +// Progress tracking for resume functionality type ProgressState = { completedSessions: string[] sessionNotes: Record @@ -336,6 +362,7 @@ function createProgressManager(progressFilePath: string) { if (!fs.existsSync(resolvedPath)) return null try { const data = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + console.log(`📁 Loaded progress: ${data.completedSessions?.length || 0} sessions completed`) return data } catch (e: any) { console.warn(`⚠️ Failed to load progress: ${e.message}`) @@ -344,9 +371,6 @@ function createProgressManager(progressFilePath: string) { } function initFresh(): ProgressState { - if (fs.existsSync(resolvedPath)) { - try { fs.unlinkSync(resolvedPath) } catch {} - } return { completedSessions: [], sessionNotes: {}, @@ -355,7 +379,11 @@ function createProgressManager(progressFilePath: string) { } function save(progress: ProgressState) { - try { fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) } catch {} + try { + fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) + } catch (e: any) { + console.warn(`⚠️ Failed to save progress: ${e.message}`) + } } function isSessionDone(id: string, p: ProgressState) { @@ -367,58 +395,136 @@ function createProgressManager(progressFilePath: string) { p.sessionNotes[id] = noteUrl } - return { load, initFresh, save, isSessionDone, markSessionDone, progressFilePath: resolvedPath } + function finalize(progress: ProgressState, options: { testMode?: boolean } = {}) { + if (!fs.existsSync(resolvedPath)) return + + progress.completedAt = new Date().toISOString() + save(progress) + + if (!options.testMode) { + try { + fs.unlinkSync(resolvedPath) + console.log(`\n🧹 Cleaned up progress file`) + } catch {} + } + } + + return { + load, + initFresh, + save, + isSessionDone, + markSessionDone, + finalize + } } /** * Main function that orchestrates the entire book mode note creation process + * Enhanced with production-ready features from proven conference implementations */ async function main(): Promise { + // Initialize API client configuration + const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://api.hackmd.io/v1' + const webDomain = process.env.HACKMD_WEB_DOMAIN || process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' + + console.log(`🚀 Starting ${CONFERENCE_NAME} note generation...`) + console.log(`📊 Configuration:`) + console.log(` Team: ${TEAM_PATH}`) + console.log(` API Endpoint: ${apiEndpoint}`) + console.log(` Test Mode: ${TEST_MODE}`) + console.log(` Resume Mode: ${RESUME_MODE}`) + console.log(` Request Delay: ${CLI_REQUEST_DELAY_MS}ms`) + // Validate required environment variables if (!process.env.HACKMD_ACCESS_TOKEN) { - console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.') + console.error('❌ Error: HACKMD_ACCESS_TOKEN environment variable is not set.') console.error('Please set your HackMD access token using one of these methods:') console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here') console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here') + console.error('3. Get your token from: https://hackmd.io/@hackmd-api/developer-portal') + process.exit(1) + } + + const apiOptions: any = { + wrapResponseErrors: true, + timeout: 60000 + } + + if (CLI_REQUEST_DELAY_MS > 0) { + apiOptions.retryConfig = { + maxRetries: 3, + baseDelay: CLI_REQUEST_DELAY_MS + } + } + + const api = new API(process.env.HACKMD_ACCESS_TOKEN!, apiEndpoint, apiOptions) + + // Verify authentication + try { + console.log('🔐 Verifying authentication...') + await api.getMe() + console.log(`✅ Authentication verified`) + } catch (error: any) { + console.error(`❌ Authentication failed: ${error.message}`) + console.error('Please check:') + console.error('1. Your HACKMD_ACCESS_TOKEN is correct') + console.error('2. Your token has the required permissions') + console.error('3. Your API endpoint is correct') + console.error('4. Your network connection to HackMD') process.exit(1) } - // Initialize API client - const api = new API(process.env.HACKMD_ACCESS_TOKEN, process.env.HACKMD_API_ENDPOINT) - // Load and process session data - console.log('Loading session data...') + console.log('📂 Loading session data...') const sessionList = loadAndProcessSessions() - console.log(`Processing ${sessionList.length} sessions...`) + console.log(`📊 Found ${sessionList.length} content sessions to process`) + + // Apply test mode filtering + if (TEST_MODE) { + console.log(`⚠️ TEST MODE: Processing only first 3 sessions`) + sessionList.splice(3) + } // Progress/resume support - const pm = createProgressManager(path.join(__dirname, 'progress.json')) - const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') - let progress: ProgressState | null = null + const pm = createProgressManager(PROGRESS_FILE) + let progress: ProgressState + if (RESUME_MODE) { - progress = pm.load() - if (!progress) { - console.error('No progress.json found. Start without --resume to create it.') + const loadedProgress = pm.load() + if (!loadedProgress) { + console.error('❌ No progress.json found. Start without --resume to create it.') process.exit(1) } + progress = loadedProgress console.log(`🔄 Resume mode: ${progress.completedSessions.length} sessions already created`) + if (progress.failedSessions?.length) { + console.log(`⚠️ ${progress.failedSessions.length} sessions previously failed`) + } } else { progress = pm.initFresh() + progress.totalSessions = sessionList.length console.log('🚀 Fresh run: progress initialized') } // Create individual session notes - console.log('\n=== Creating Individual Session Notes ===') - for (let data of sessionList) { - if (pm.isSessionDone(data.id, progress!)) { - // restore URL - if (progress!.sessionNotes[data.id]) data.noteUrl = progress!.sessionNotes[data.id].replace(`${getHackMDHost()}/`, '') - console.log(`⏭️ Skip existing: ${data.title}`) + console.log('\n📝 Creating individual session notes...') + let processedCount = 0 + let skippedCount = 0 + + for (const data of sessionList) { + if (pm.isSessionDone(data.id, progress)) { + // Restore URL from progress + if (progress.sessionNotes[data.id]) { + data.noteUrl = progress.sessionNotes[data.id].replace(`${webDomain}/`, '') + } + console.log(`✅ Session "${data.title}" already completed, skipping`) + skippedCount++ continue } const noteContent = generateSessionNoteContent(data) - + const noteData = { title: data.title, content: noteContent, @@ -427,59 +533,100 @@ async function main(): Promise { } try { + console.log(`📝 Creating note for: ${data.title}`) const note = await api.createTeamNote(TEAM_PATH, noteData) data.noteUrl = note.shortId - pm.markSessionDone(data.id, `${getHackMDHost()}/${note.shortId}`, progress!) - pm.save(progress!) - console.log(`✓ Created note for: ${data.title}`) + + const noteUrl = `${webDomain}/${note.shortId}` + pm.markSessionDone(data.id, noteUrl, progress) + processedCount++ + + // Save progress every 5 sessions + if (processedCount % 5 === 0) { + pm.save(progress) + console.log(`💾 Progress saved (${processedCount} sessions processed)`) + } + + console.log(`✅ Created: ${noteUrl}`) + + // Add delay between requests if configured + if (CLI_REQUEST_DELAY_MS > 0) { + await new Promise(resolve => setTimeout(resolve, CLI_REQUEST_DELAY_MS)) + } + } catch (error: any) { - console.error(`✗ Failed to create note for ${data.title}:`, error.message) + console.error(`❌ Failed to create note for "${data.title}": ${error.message}`) data.noteUrl = 'error' + pm.save(progress) } } - // Output session URLs for reference - const hackmdHost = getHackMDHost() - const sessionUrls: SessionUrl[] = sessionList - .filter(s => s.noteUrl !== 'error') - .map(s => ({ - id: s.id, - url: `${hackmdHost}/${s.noteUrl}`, - title: s.title - })) - - console.log('\n=== Session URLs ===') - console.log(JSON.stringify(sessionUrls, null, 2)) - - // Create nested structure for the main book - const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) - const bookContent = generateBookContent(nestedSessions, 1) + // Final progress save + pm.save(progress) + console.log(`✅ Session notes creation completed (${processedCount} new notes, ${skippedCount} skipped)`) - // Create main conference book - console.log('\n=== Creating Main Conference Book ===') - const mainBookContent = generateMainBookContent(bookContent) + // Create main conference book if not already created + if (progress.mainBookCreated) { + console.log(`\n✅ Main book already created: ${progress.mainBookUrl}`) + } else { + console.log('\n📚 Creating main conference book...') - try { - const mainBook = await api.createTeamNote(TEAM_PATH, { - title: `${CONFERENCE_CONFIG.name} 共同筆記`, - content: mainBookContent, - readPermission: NotePermissionRole.GUEST as any, - writePermission: NotePermissionRole.SIGNED_IN as any - }) + // Filter successful sessions for the book + const successfulSessions = sessionList.filter(s => s.noteUrl && s.noteUrl !== 'error') + const nestedSessions = nest(successfulSessions, ['day', 'startTime']) + const bookContent = generateBookContent(nestedSessions, 1) + const mainBookContent = generateMainBookContent(bookContent) - console.log('\n=== Main Conference Book Created ===') - console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) - console.log('\n🎉 Book mode conference notes created successfully!') - console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) - if (progress) { + try { + const mainBook = await api.createTeamNote(TEAM_PATH, { + title: `${CONFERENCE_NAME} 共同筆記`, + content: mainBookContent, + readPermission: NotePermissionRole.GUEST as any, + writePermission: NotePermissionRole.SIGNED_IN as any + }) + + const mainBookUrl = `${webDomain}/${mainBook.shortId}` progress.mainBookCreated = true - progress.mainBookUrl = `${hackmdHost}/${mainBook.shortId}` - progress.completedAt = new Date().toISOString() + progress.mainBookUrl = mainBookUrl pm.save(progress) + + console.log(`✅ Main book created: ${mainBookUrl}`) + } catch (error: any) { + console.error(`❌ Failed to create main book: ${error.message}`) } - } catch (error: any) { - console.error('✗ Failed to create main book:', error.message) } + + // Final statistics and cleanup + const successfulSessions = sessionList.filter(s => s.noteUrl && s.noteUrl !== 'error') + const failedSessions = sessionList.filter(s => s.noteUrl === 'error') + + console.log(`\n🎉 Generation completed!`) + console.log(`📚 Main book: ${progress.mainBookUrl || 'Failed to create'}`) + console.log(`📊 Statistics:`) + console.log(` ✅ Successful sessions: ${successfulSessions.length}`) + console.log(` ❌ Failed sessions: ${failedSessions.length}`) + console.log(` 📝 Total sessions processed: ${sessionList.length}`) + + if (failedSessions.length > 0) { + console.log(`\n⚠️ Failed sessions:`) + failedSessions.forEach(s => console.log(` - ${s.title}`)) + console.log(`\nTo retry failed sessions, fix any issues and run with --resume flag.`) + } + + // Output session URLs for reference + if (successfulSessions.length > 0) { + const sessionUrls: SessionUrl[] = successfulSessions.map(s => ({ + id: s.id, + url: `${webDomain}/${s.noteUrl}`, + title: s.title + })) + + console.log('\n📋 Session URLs:') + sessionUrls.forEach(s => console.log(` ${s.title}: ${s.url}`)) + } + + // Finalize progress + pm.finalize(progress, { testMode: TEST_MODE }) } // ========================================== @@ -488,8 +635,21 @@ async function main(): Promise { // Run the script when executed directly if (import.meta.url === `file://${process.argv[1]}`) { - main().catch(console.error) + main().catch((error) => { + console.error('\n💥 Generation failed:', error) + console.error('\nTroubleshooting:') + console.error('1. Check your HACKMD_ACCESS_TOKEN is valid') + console.error('2. Verify team permissions for note creation') + console.error('3. Check network connectivity') + console.error('4. Try running with --test first') + console.error('5. Use --resume to continue from last successful point') + console.error('\nFor production environments:') + console.error('- Use --delay-ms to avoid rate limits') + console.error('- Monitor progress.json for recovery') + console.error('- Check API quota limits') + process.exit(1) + }) } // Export functions for potential module usage -export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } \ No newline at end of file +export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } diff --git a/examples/book-mode-conference/package-lock.json b/examples/book-mode-conference/package-lock.json index 839b6fc..62815fe 100644 --- a/examples/book-mode-conference/package-lock.json +++ b/examples/book-mode-conference/package-lock.json @@ -22,7 +22,7 @@ }, "../../nodejs": { "name": "@hackmd/api", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "dependencies": { "axios": "^1.8.4", diff --git a/examples/book-mode-conference/sessions.json b/examples/book-mode-conference/sessions.json index 829ad2f..d9a7d36 100644 --- a/examples/book-mode-conference/sessions.json +++ b/examples/book-mode-conference/sessions.json @@ -1,7 +1,7 @@ [ { "id": "session-001", - "title": "Welcome to DevOpsDays", + "title": "Opening Keynote: The Future of Technology", "speaker": [ { "speaker": { @@ -12,7 +12,7 @@ "session_type": "keynote", "started_at": "2025-03-15T09:00:00Z", "finished_at": "2025-03-15T09:30:00Z", - "tags": ["welcome", "keynote"], + "tags": ["keynote", "future", "technology"], "classroom": { "tw_name": "主舞台", "en_name": "Main Stage" @@ -22,7 +22,7 @@ }, { "id": "session-002", - "title": "Introduction to CI/CD", + "title": "Advanced Cloud Architecture", "speaker": [ { "speaker": { @@ -33,7 +33,7 @@ "session_type": "talk", "started_at": "2025-03-15T10:00:00Z", "finished_at": "2025-03-15T10:45:00Z", - "tags": ["ci", "cd", "automation"], + "tags": ["cloud", "architecture", "scalability"], "classroom": { "tw_name": "A會議室", "en_name": "Room A" @@ -43,7 +43,7 @@ }, { "id": "session-003", - "title": "Advanced Kubernetes Operations", + "title": "Machine Learning in Production", "speaker": [ { "speaker": { @@ -59,7 +59,7 @@ "session_type": "workshop", "started_at": "2025-03-15T11:00:00Z", "finished_at": "2025-03-15T12:00:00Z", - "tags": ["kubernetes", "containers", "orchestration"], + "tags": ["ml", "ai", "production"], "classroom": { "tw_name": "B會議室", "en_name": "Room B" @@ -69,7 +69,7 @@ }, { "id": "session-004", - "title": "DevOps Culture and Practices", + "title": "Microservices Design Patterns", "speaker": [ { "speaker": { @@ -80,7 +80,7 @@ "session_type": "talk", "started_at": "2025-03-15T14:00:00Z", "finished_at": "2025-03-15T14:45:00Z", - "tags": ["culture", "practices", "team"], + "tags": ["microservices", "design", "patterns"], "classroom": { "tw_name": "主舞台", "en_name": "Main Stage" @@ -90,7 +90,7 @@ }, { "id": "session-005", - "title": "監控與可觀測性", + "title": "資料科學實務應用", "speaker": [ { "speaker": { @@ -101,7 +101,7 @@ "session_type": "talk", "started_at": "2025-03-16T09:30:00Z", "finished_at": "2025-03-16T10:15:00Z", - "tags": ["monitoring", "observability"], + "tags": ["data-science", "analytics"], "classroom": { "tw_name": "A會議室", "en_name": "Room A" @@ -111,7 +111,7 @@ }, { "id": "session-006", - "title": "Security in DevOps Pipeline", + "title": "Cybersecurity Best Practices", "speaker": [ { "speaker": { @@ -122,7 +122,7 @@ "session_type": "workshop", "started_at": "2025-03-16T10:30:00Z", "finished_at": "2025-03-16T12:00:00Z", - "tags": ["security", "devsecops", "pipeline"], + "tags": ["security", "cybersecurity", "best-practices"], "classroom": { "tw_name": "C會議室", "en_name": "Room C" From 9de781fa3f3005abdb525ace7bd4faaa4ab664e1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:54:12 +0800 Subject: [PATCH 09/13] Support title/tag metadata updates in note PATCH APIs (#49) Agent-Logs-Url: https://github.com/hackmdio/api-client/sessions/782df678-b097-4b7e-b0c7-451b5be8bbb7 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Yukaii <4230968+Yukaii@users.noreply.github.com> --- nodejs/src/index.ts | 6 +++--- nodejs/src/type.ts | 3 ++- nodejs/tests/api.spec.ts | 30 ++++++++++++++++++++++++++++++ nodejs/tests/etag.spec.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index e625ee5..f816370 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios' -import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote } from './type' +import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote, UpdateNoteOptions } from './type' import * as HackMDErrors from './error' export type RequestOptions = { @@ -165,7 +165,7 @@ export class API { return this.unwrapData(this.axios.patch(`notes/${noteId}`, { content }), options.unwrapData, true) as unknown as OptionReturnType } - async updateNote (noteId: string, payload: Partial>, options = defaultOption as Opt): Promise> { + async updateNote (noteId: string, payload: UpdateNoteOptions, options = defaultOption as Opt): Promise> { return this.unwrapData(this.axios.patch(`notes/${noteId}`, payload), options.unwrapData, true) as unknown as OptionReturnType } @@ -189,7 +189,7 @@ export class API { return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, { content }) } - async updateTeamNote (teamPath: string, noteId: string, options: Partial>): Promise { + async updateTeamNote (teamPath: string, noteId: string, options: UpdateNoteOptions): Promise { return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, options) } diff --git a/nodejs/src/type.ts b/nodejs/src/type.ts index 766b292..76b24c9 100644 --- a/nodejs/src/type.ts +++ b/nodejs/src/type.ts @@ -85,6 +85,8 @@ export type SingleNote = Note & { content: string } +export type UpdateNoteOptions = Partial> + // User export type GetMe = User @@ -105,4 +107,3 @@ export type CreateTeamNote = SingleNote export type UpdateTeamNote = void export type DeleteTeamNote = void - diff --git a/nodejs/tests/api.spec.ts b/nodejs/tests/api.spec.ts index 74a9aaf..2ba704b 100644 --- a/nodejs/tests/api.spec.ts +++ b/nodejs/tests/api.spec.ts @@ -98,3 +98,33 @@ test('should throw HackMD error object', async () => { expect(error).toHaveProperty('resetAfter') } }) + +test('should support updating team note title and tags metadata', async () => { + const updatedTags = ['team', 'metadata'] + let requestBody: unknown + + server.use( + http.patch('https://api.hackmd.io/v1/teams/test-team/notes/test-note-id', async ({ request }) => { + requestBody = await request.json() + + return HttpResponse.json( + { + id: 'test-note-id', + title: 'Updated Team Note', + tags: updatedTags + } + ) + }) + ) + + const response = await client.updateTeamNote('test-team', 'test-note-id', { + title: 'Updated Team Note', + tags: updatedTags + }) + + expect(requestBody).toEqual({ + title: 'Updated Team Note', + tags: updatedTags + }) + expect(response).toHaveProperty('status', 200) +}) diff --git a/nodejs/tests/etag.spec.ts b/nodejs/tests/etag.spec.ts index 1da5788..ce0d2ae 100644 --- a/nodejs/tests/etag.spec.ts +++ b/nodejs/tests/etag.spec.ts @@ -268,5 +268,44 @@ describe('Etag support', () => { expect(response).toHaveProperty('title', 'Updated Test Note') expect(response).toHaveProperty('content', 'Updated content via updateNote') }) + + test('should support updating note title and tags metadata', async () => { + const mockEtag = 'W/"metadata-etag"' + const updatedTags = ['api', 'metadata'] + let requestBody: unknown + + server.use( + http.patch('https://api.hackmd.io/v1/notes/test-note-id', async ({ request }) => { + requestBody = await request.json() + + return HttpResponse.json( + { + id: 'test-note-id', + title: 'Updated Metadata Title', + tags: updatedTags, + content: 'Updated content via updateNote' + }, + { + headers: { + 'ETag': mockEtag + } + } + ) + }) + ) + + const response = await client.updateNote('test-note-id', { + title: 'Updated Metadata Title', + tags: updatedTags + }) + + expect(requestBody).toEqual({ + title: 'Updated Metadata Title', + tags: updatedTags + }) + expect(response).toHaveProperty('etag', mockEtag) + expect(response).toHaveProperty('title', 'Updated Metadata Title') + expect(response.tags).toEqual(updatedTags) + }) }) }) From bfd31b460f2b6ca6d3290933dd61d2ff95600a77 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 30 Apr 2026 11:31:55 -0700 Subject: [PATCH 10/13] feat(nodejs): add folder API support and live e2e coverage (#52) Extend the Node.js client with user and team folder APIs, folder-aware note options, and exported folder types. Add an opt-in live e2e test suite and keep non-idempotent retries from masking server-side POST failures. Made-with: Cursor --- .github/workflows/e2e.yml | 36 ++++ nodejs/README.md | 33 ++++ nodejs/jest.config.ts | 1 + nodejs/jest.e2e.config.ts | 14 ++ nodejs/package.json | 3 +- nodejs/src/index.ts | 104 +++++++++++- nodejs/src/type.ts | 70 +++++++- nodejs/tests/api.spec.ts | 76 ++++++++- nodejs/tests/e2e/api.e2e.spec.ts | 276 +++++++++++++++++++++++++++++++ 9 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 nodejs/jest.e2e.config.ts create mode 100644 nodejs/tests/e2e/api.e2e.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a968464 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,36 @@ +# Optional live API checks. Add repository secret HACKMD_E2E_ACCESS_TOKEN. +# Optionally add HACKMD_E2E_API_ENDPOINT (e.g. https://api-stage.hackmd.io/v1); otherwise production is used. + +name: E2E (live HackMD API) + +on: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: nodejs/package-lock.json + + - name: Install dependencies + working-directory: nodejs + run: npm ci + + - name: Run e2e tests + working-directory: nodejs + env: + HACKMD_ACCESS_TOKEN: ${{ secrets.HACKMD_E2E_ACCESS_TOKEN }} + HACKMD_API_ENDPOINT: ${{ secrets.HACKMD_E2E_API_ENDPOINT }} + run: | + if [ -z "${HACKMD_ACCESS_TOKEN:-}" ]; then + echo "::error::Add repository secret HACKMD_E2E_ACCESS_TOKEN (a valid API token for the target environment)." + exit 1 + fi + npm run test:e2e diff --git a/nodejs/README.md b/nodejs/README.md index f003c09..56b7d94 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -121,6 +121,39 @@ const updatedNote = await client.getNote('note-id', { etag }) See the [code](./src/index.ts) and [typings](./src/type.ts). The API client is written in TypeScript, so you can get auto-completion and type checking in any TypeScript Language Server powered editor or IDE. +## E2E tests (live API) + +Integration tests call a real HackMD API (staging or production). They are **not** run by `npm test` or the default CI job. + +**Requirements** + +- `HACKMD_ACCESS_TOKEN` — a valid personal access token for the environment you target. +- Optional: `HACKMD_API_ENDPOINT` — defaults to `https://api.hackmd.io/v1`. For staging, use `https://api-stage.hackmd.io/v1`. + +**Read-only (default e2e)** + +```bash +cd nodejs +export HACKMD_ACCESS_TOKEN=your_token +export HACKMD_API_ENDPOINT=https://api-stage.hackmd.io/v1 # optional +npm run test:e2e +``` + +**With CRUD / mutations** + +Set `HACKMD_E2E_MUTATIONS=1` to run write tests against your account: + +- **Notes:** create → get → update (title, content, tags) → list → delete. +- **Folders:** one integration test runs create (root + nested) → get → update → list → folder-order round-trip (skipped if that API returns 404) → delete. If **POST `/folders`** returns 404 (common before full production rollout), the test exits early with a warning; use staging or `HACKMD_E2E_FOLDERS=0`. + +```bash +HACKMD_E2E_MUTATIONS=1 npm run test:e2e +``` + +Folder CRUD touches folder display order briefly, then restores the previous order in an `afterAll` hook. To skip folder mutations (e.g. production without `/folders`), set `HACKMD_E2E_FOLDERS=0`. + +The read-only `getFolderList` test still treats HTTP 404 as “folders not available on this host yet” and passes without failing the suite. + ## License MIT diff --git a/nodejs/jest.config.ts b/nodejs/jest.config.ts index 234ac01..838e7e1 100644 --- a/nodejs/jest.config.ts +++ b/nodejs/jest.config.ts @@ -6,6 +6,7 @@ const customJestConfig: JestConfigWithTsJest = { transformIgnorePatterns: ["/node_modules/"], extensionsToTreatAsEsm: [".ts"], setupFiles: ["dotenv/config"], + testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], } export default customJestConfig diff --git a/nodejs/jest.e2e.config.ts b/nodejs/jest.e2e.config.ts new file mode 100644 index 0000000..c818758 --- /dev/null +++ b/nodejs/jest.e2e.config.ts @@ -0,0 +1,14 @@ +import type { JestConfigWithTsJest } from "ts-jest" + +/** Live API tests; run with `npm run test:e2e` (see nodejs/README.md). */ +const e2eJestConfig: JestConfigWithTsJest = { + preset: "ts-jest", + testEnvironment: "node", + transformIgnorePatterns: ["/node_modules/"], + extensionsToTreatAsEsm: [".ts"], + setupFiles: ["dotenv/config"], + testMatch: ["/tests/e2e/**/*.spec.ts"], + testTimeout: 60_000, +} + +export default e2eJestConfig diff --git a/nodejs/package.json b/nodejs/package.json index 65bc608..eb0bbbc 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -25,7 +25,8 @@ "watch": "npm run clean && rollup -c -w", "prepublishOnly": "npm run build", "lint": "eslint src --fix --ext .ts", - "test": "jest" + "test": "jest", + "test:e2e": "jest --config jest.e2e.config.ts" }, "keywords": [ "HackMD", diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f816370..3389ee4 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -1,5 +1,32 @@ import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios' -import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote, UpdateNoteOptions } from './type' +import { + CreateNoteOptions, + CreateTeamFolderBody, + CreateUserFolderBody, + GetMe, + GetUserHistory, + GetUserNotes, + GetUserNote, + CreateUserNote, + GetUserTeams, + GetTeamNotes, + CreateTeamNote, + SingleNote, + UpdateNoteOptions, + GetFolders, + GetFolder, + GetFolderOrder, + CreateFolderResult, + UpdateFolderResult, + GetTeamFolders, + GetTeamFolder, + GetTeamFolderOrder, + CreateTeamFolderResult, + UpdateTeamFolderResult, + UpdateFolderOrderBody, + UpdateTeamFolderBody, + UpdateUserFolderBody, +} from './type' import * as HackMDErrors from './error' export type RequestOptions = { @@ -59,6 +86,10 @@ export class API { } ) + if (options.retryConfig) { + this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) + } + if (options.wrapResponseErrors) { this.axios.interceptors.response.use( (response: AxiosResponse) => { @@ -94,16 +125,21 @@ export class API { } ) } - if (options.retryConfig) { - this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) - } } private exponentialBackoff (retries: number, baseDelay: number): number { return Math.pow(2, retries) * baseDelay } - private isRetryableError (error: AxiosError): boolean { + private isRetryableMethod (method?: string): boolean { + if (!method) return false + const normalized = method.toLowerCase() + return ['get', 'head', 'options', 'put', 'delete'].includes(normalized) + } + + private isRetryableError (error: unknown): boolean { + if (!axios.isAxiosError(error)) return false + if (!this.isRetryableMethod(error.config?.method)) return false return ( !error.response || (error.response.status >= 500 && error.response.status < 600) || @@ -197,6 +233,62 @@ export class API { return this.axios.delete(`teams/${teamPath}/notes/${noteId}`) } + async getFolderList (options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get('folders'), options.unwrapData) as unknown as OptionReturnType + } + + async createFolder (payload: CreateUserFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.post('folders', payload), options.unwrapData) as unknown as OptionReturnType + } + + async getFolder (folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async updateFolder (folderId: string, payload: UpdateUserFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.patch(`folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async deleteFolder (folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.delete(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async getFolderOrder (options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get('folders/folder-order'), options.unwrapData) as unknown as OptionReturnType + } + + async updateFolderOrder (payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.put('folders/folder-order', payload), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolderList (teamPath: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders`), options.unwrapData) as unknown as OptionReturnType + } + + async createTeamFolder (teamPath: string, payload: CreateTeamFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.post(`teams/${teamPath}/folders`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolder (teamPath: string, folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async updateTeamFolder (teamPath: string, folderId: string, payload: UpdateTeamFolderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.patch(`teams/${teamPath}/folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType + } + + async deleteTeamFolder (teamPath: string, folderId: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.delete(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType + } + + async getTeamFolderOrder (teamPath: string, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.get(`teams/${teamPath}/folders/folder-order`), options.unwrapData) as unknown as OptionReturnType + } + + async updateTeamFolderOrder (teamPath: string, payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise> { + return this.unwrapData(this.axios.put(`teams/${teamPath}/folders/folder-order`, payload), options.unwrapData) as unknown as OptionReturnType + } + private unwrapData (reqP: Promise>, unwrap = true, includeEtag = false) { if (!unwrap) { // For raw responses, etag is available via response.headers @@ -211,4 +303,6 @@ export class API { } } +export * from './type' + export default API diff --git a/nodejs/src/type.ts b/nodejs/src/type.ts index 76b24c9..50eb3f3 100644 --- a/nodejs/src/type.ts +++ b/nodejs/src/type.ts @@ -21,10 +21,13 @@ export enum CommentPermissionType { export type CreateNoteOptions = { title?: string content?: string + description?: string + tags?: string[] readPermission?: NotePermissionRole, writePermission?: NotePermissionRole, commentPermission?: CommentPermissionType, permalink?: string + parentFolderId?: string } export type Team = { @@ -62,6 +65,16 @@ export enum NotePermissionRole { GUEST = 'guest' } +/** Folder breadcrumb segment as returned on notes (OpenAPI `FolderPath`). */ +export type FolderPath = { + id: string + name: string + icon: string | null + color: string | null + parentId: string | null + clientId: string +} + export type Note = { id: string title: string @@ -79,13 +92,16 @@ export type Note = { readPermission: NotePermissionRole writePermission: NotePermissionRole + folderPaths?: FolderPath[] } export type SingleNote = Note & { content: string } -export type UpdateNoteOptions = Partial> +export type UpdateNoteOptions = Partial> & { + parentFolderId?: string +} // User export type GetMe = User @@ -107,3 +123,55 @@ export type CreateTeamNote = SingleNote export type UpdateTeamNote = void export type DeleteTeamNote = void +// Folders (user & team workspaces) +export type ApiFolder = { + id: string + name: string + description: string | null + icon: string | null + color: string | null + parentFolderId: string | null + createdAt: number + updatedAt: number +} + +/** Maps each parent folder id or the literal `root` to ordered child folder ids. */ +export type ApiFolderOrder = Record + +export type CreateUserFolderBody = { + name?: string + description?: string + icon?: string + color?: string + parentFolderId?: string +} + +export type UpdateUserFolderBody = { + name?: string + description?: string | null + icon?: string | null + color?: string | null + parentFolderId?: string | null +} + +export type CreateTeamFolderBody = CreateUserFolderBody + +export type UpdateTeamFolderBody = UpdateUserFolderBody + +export type UpdateFolderOrderBody = { + order: ApiFolderOrder +} + +export type GetFolders = ApiFolder[] +export type GetTeamFolders = ApiFolder[] +export type GetFolder = ApiFolder +export type GetTeamFolder = ApiFolder +export type CreateFolderResult = ApiFolder +export type CreateTeamFolderResult = ApiFolder +export type UpdateFolderResult = ApiFolder +export type UpdateTeamFolderResult = ApiFolder +export type DeleteFolderResult = void +export type DeleteTeamFolderResult = void +export type GetFolderOrder = ApiFolderOrder +export type GetTeamFolderOrder = ApiFolderOrder + diff --git a/nodejs/tests/api.spec.ts b/nodejs/tests/api.spec.ts index 2ba704b..9f3f28f 100644 --- a/nodejs/tests/api.spec.ts +++ b/nodejs/tests/api.spec.ts @@ -1,7 +1,7 @@ import { server } from './mock' import { API } from '../src' import { http, HttpResponse } from 'msw' -import { TooManyRequestsError } from '../src/error' +import { InternalServerError, TooManyRequestsError } from '../src/error' let client: API @@ -99,6 +99,50 @@ test('should throw HackMD error object', async () => { } }) +test('getFolderList returns folders from /folders', async () => { + server.use( + http.get('https://api.hackmd.io/v1/folders', () => { + return HttpResponse.json([ + { + id: 'folder-1', + name: 'Research', + description: null, + icon: null, + color: null, + parentFolderId: null, + createdAt: 1700000000, + updatedAt: 1700000001, + }, + ]) + }), + ) + + const folders = await client.getFolderList() + + expect(folders).toHaveLength(1) + expect(folders[0]).toMatchObject({ id: 'folder-1', name: 'Research' }) +}) + +test('updateFolderOrder sends order payload', async () => { + let requestBody: unknown + + server.use( + http.put('https://api.hackmd.io/v1/folders/folder-order', async ({ request }) => { + requestBody = await request.json() + + return HttpResponse.json({}) + }), + ) + + await client.updateFolderOrder({ + order: { root: ['a', 'b'], parent: ['c'] }, + }) + + expect(requestBody).toEqual({ + order: { root: ['a', 'b'], parent: ['c'] }, + }) +}) + test('should support updating team note title and tags metadata', async () => { const updatedTags = ['team', 'metadata'] let requestBody: unknown @@ -128,3 +172,33 @@ test('should support updating team note title and tags metadata', async () => { }) expect(response).toHaveProperty('status', 200) }) + +test('should not retry non-idempotent requests when wrapping errors', async () => { + let requestCount = 0 + const clientWithRetryAndWrap = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { + wrapResponseErrors: true, + retryConfig: { + maxRetries: 2, + baseDelay: 1, + }, + }) + + server.use( + http.post('https://api.hackmd.io/v1/folders', () => { + requestCount += 1 + if (requestCount === 1) { + return HttpResponse.json( + { error: 'Folder created but could not be retrieved' }, + { status: 500 }, + ) + } + + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) + }), + ) + + await expect( + clientWithRetryAndWrap.createFolder({ name: 'retry-safety-test' }), + ).rejects.toBeInstanceOf(InternalServerError) + expect(requestCount).toBe(1) +}) diff --git a/nodejs/tests/e2e/api.e2e.spec.ts b/nodejs/tests/e2e/api.e2e.spec.ts new file mode 100644 index 0000000..8729e6b --- /dev/null +++ b/nodejs/tests/e2e/api.e2e.spec.ts @@ -0,0 +1,276 @@ +import type { ApiFolderOrder } from '../../src' +import { API } from '../../src' +import { HttpResponseError } from '../../src/error' + +const mutationsEnabled = process.env.HACKMD_E2E_MUTATIONS === '1' +/** Set to `0` to skip folder CRUD (e.g. host without `/folders`). */ +const folderCrudEnabled = process.env.HACKMD_E2E_FOLDERS !== '0' + +function assertToken (token: string | undefined): asserts token is string { + if (!token?.trim()) { + throw new Error( + 'E2E tests require HACKMD_ACCESS_TOKEN (see nodejs/README.md — "E2E tests").', + ) + } +} + +function isNotFound (err: unknown): boolean { + return err instanceof HttpResponseError && err.code === 404 +} + +describe('HackMD API (live e2e)', () => { + let client: API + + beforeAll(() => { + const token = process.env.HACKMD_ACCESS_TOKEN + assertToken(token) + const endpoint = + process.env.HACKMD_API_ENDPOINT?.trim() || 'https://api.hackmd.io/v1' + client = new API(token.trim(), endpoint, { + wrapResponseErrors: true, + retryConfig: { maxRetries: 2, baseDelay: 250 }, + }) + }) + + describe('read-only', () => { + it('getMe returns the current user profile', async () => { + const me = await client.getMe() + + expect(me).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + userPath: expect.any(String), + }) + expect(Array.isArray(me.teams)).toBe(true) + }) + + it('getNoteList returns an array', async () => { + const notes = await client.getNoteList() + expect(Array.isArray(notes)).toBe(true) + if (notes.length > 0) { + expect(notes[0]).toMatchObject({ + id: expect.any(String), + title: expect.any(String), + }) + } + }) + + it('getTeams returns an array', async () => { + const teams = await client.getTeams() + expect(Array.isArray(teams)).toBe(true) + }) + + it('getHistory accepts limit and returns an array', async () => { + const history = await client.getHistory() + expect(Array.isArray(history)).toBe(true) + }) + + it('getFolderList returns folders when the server exposes /folders', async () => { + try { + const folders = await client.getFolderList() + expect(Array.isArray(folders)).toBe(true) + if (folders.length > 0) { + expect(folders[0]).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + }) + } + } catch (err) { + if (isNotFound(err)) { + // Host may not expose /folders yet (e.g. production before rollout). + return + } + throw err + } + }) + }) + + describe('mutations (optional)', () => { + const describeMutations = mutationsEnabled ? describe : describe.skip + + describeMutations('notes CRUD when HACKMD_E2E_MUTATIONS=1', () => { + const stamp = Date.now() + let noteId: string + + afterAll(async () => { + if (!noteId) return + try { + await client.deleteNote(noteId) + } catch { + /* already removed */ + } + }) + + it('createNote creates a note', async () => { + const title = `e2e-note-${stamp}` + const created = await client.createNote({ + title, + content: '# initial\n', + tags: ['e2e'], + }) + + expect(created.id).toEqual(expect.any(String)) + expect(created.title).toBe(title) + expect(created.tags).toContain('e2e') + noteId = created.id + }) + + it('getNote returns the note', async () => { + const n = await client.getNote(noteId) + expect(n.id).toBe(noteId) + expect(n.title).toBe(`e2e-note-${stamp}`) + expect(n.content).toContain('initial') + }) + + it('updateNote updates title, content, and tags', async () => { + const title = `e2e-note-${stamp}-patched` + const patch = await client.updateNote(noteId, { + title, + content: '# patched\n\nbody', + tags: ['e2e', 'updated'], + }, { unwrapData: false }) + + expect([200, 202]).toContain(patch.status) + const patchedBody = patch.data as { content?: string } + if (typeof patchedBody.content === 'string' && patchedBody.content.length > 0) { + expect(patchedBody.content).toContain('patched') + } + + const n = await client.getNote(noteId) + expect(n.title).toBe(title) + expect(n.tags).toEqual(expect.arrayContaining(['e2e', 'updated'])) + if (typeof n.content === 'string' && n.content.length > 0) { + expect(n.content).toContain('patched') + } + }) + + it('getNoteList includes the note', async () => { + const list = await client.getNoteList() + const found = list.find(n => n.id === noteId) + expect(found).toBeDefined() + expect(found!.title).toBe(`e2e-note-${stamp}-patched`) + }) + + it('deleteNote removes the note', async () => { + await client.deleteNote(noteId) + const list = await client.getNoteList() + expect(list.find(n => n.id === noteId)).toBeUndefined() + noteId = '' + }) + }) + + const describeFolderMutations = + mutationsEnabled && folderCrudEnabled ? describe : describe.skip + + describeFolderMutations('folders CRUD when HACKMD_E2E_MUTATIONS=1', () => { + it('folders: create → get → update → nested folder → list → order round-trip → delete', async () => { + let parentFolderId = '' + let childFolderId = '' + let orderBeforeMutation: ApiFolderOrder | null = null + + const t0 = Date.now() + let created + try { + created = await client.createFolder({ + name: `e2e-parent-${t0}`, + description: 'e2e parent', + }) + } catch (err) { + if (isNotFound(err)) { + console.warn( + '[e2e] POST /folders returned 404 (folder writes not on this host). ' + + 'Use https://api-stage.hackmd.io/v1 or set HACKMD_E2E_FOLDERS=0.', + ) + expect(isNotFound(err)).toBe(true) + return + } + throw err + } + + expect(created.id).toEqual(expect.any(String)) + expect(created.name).toContain('e2e-parent') + parentFolderId = created.id + + try { + const folder = await client.getFolder(parentFolderId) + expect(folder.id).toBe(parentFolderId) + expect(folder.name).toContain('e2e-parent') + expect(folder.description).toBe('e2e parent') + + const renamed = `e2e-parent-renamed-${Date.now()}` + await client.updateFolder(parentFolderId, { + name: renamed, + description: 'renamed', + }) + const updated = await client.getFolder(parentFolderId) + expect(updated.name).toBe(renamed) + expect(updated.description).toBe('renamed') + + const child = await client.createFolder({ + name: `e2e-child-${Date.now()}`, + parentFolderId: parentFolderId, + }) + expect(child.id).toEqual(expect.any(String)) + childFolderId = child.id + const fetchedChild = await client.getFolder(childFolderId) + expect(fetchedChild.parentFolderId).toBe(parentFolderId) + + const list = await client.getFolderList() + const ids = new Set(list.map(f => f.id)) + expect(ids.has(parentFolderId)).toBe(true) + expect(ids.has(childFolderId)).toBe(true) + + try { + orderBeforeMutation = await client.getFolderOrder() + const root = [ + ...new Set([...(orderBeforeMutation.root ?? []), parentFolderId]), + ] + const next: ApiFolderOrder = { + ...orderBeforeMutation, + root, + } + await client.updateFolderOrder({ order: next }) + const mid = await client.getFolderOrder() + expect(mid.root).toContain(parentFolderId) + await client.updateFolderOrder({ order: orderBeforeMutation }) + const after = await client.getFolderOrder() + expect(after).toEqual(orderBeforeMutation) + } catch (err) { + if (!isNotFound(err)) throw err + console.warn( + '[e2e] folder-order API not available; skipped order round-trip.', + ) + expect(isNotFound(err)).toBe(true) + } + + await client.deleteFolder(childFolderId) + const listAfterChild = await client.getFolderList() + expect(listAfterChild.find(f => f.id === childFolderId)).toBeUndefined() + childFolderId = '' + + await client.deleteFolder(parentFolderId) + const listAfterParent = await client.getFolderList() + expect(listAfterParent.find(f => f.id === parentFolderId)).toBeUndefined() + parentFolderId = '' + } catch (err) { + for (const id of [childFolderId, parentFolderId]) { + if (!id) continue + try { + await client.deleteFolder(id) + } catch { + /* ignore */ + } + } + if (orderBeforeMutation) { + try { + await client.updateFolderOrder({ order: orderBeforeMutation }) + } catch { + /* ignore */ + } + } + throw err + } + }) + }) + }) +}) From ca54deb7160d5f609c64685b7c7f98fe87c9ffd5 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 30 Apr 2026 11:49:23 -0700 Subject: [PATCH 11/13] ci: unify publish workflow and npm trusted publishing (#53) Made-with: Cursor --- .github/workflows/pre-release.yml | 85 ---------------------------- .github/workflows/publish.yml | 94 +++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 96 deletions(-) delete mode 100644 .github/workflows/pre-release.yml diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml deleted file mode 100644 index de60d8b..0000000 --- a/.github/workflows/pre-release.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Pre-release to NPM - -on: - push: - branches: - - develop - -jobs: - pre-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - cache-dependency-path: nodejs/package-lock.json - - - name: Install dependencies - working-directory: nodejs - run: npm ci - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Generate pre-release version - working-directory: nodejs - run: | - # Get current version from package.json - CURRENT_VERSION=$(node -p "require('./package.json').version") - - # Get short commit hash - SHORT_SHA=$(git rev-parse --short HEAD) - - # Get current timestamp - TIMESTAMP=$(date +%Y%m%d%H%M%S) - - # Create pre-release version: current-version-beta.timestamp.sha - PRE_RELEASE_VERSION="${CURRENT_VERSION}-beta.${TIMESTAMP}.${SHORT_SHA}" - - echo "Pre-release version: $PRE_RELEASE_VERSION" - echo "PRE_RELEASE_VERSION=$PRE_RELEASE_VERSION" >> $GITHUB_ENV - - # Update package.json with pre-release version - npm version $PRE_RELEASE_VERSION --no-git-tag-version - - - name: Build - working-directory: nodejs - run: npm run build - - - name: Publish pre-release to NPM - working-directory: nodejs - run: npm publish --tag beta --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Create GitHub pre-release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "v${{ env.PRE_RELEASE_VERSION }}" \ - --title "Pre-release v${{ env.PRE_RELEASE_VERSION }}" \ - --notes "🚀 **Pre-release from develop branch** - - This is an automated pre-release build from the develop branch. - - **Changes:** - - Commit: ${{ github.sha }} - - Branch: ${{ github.ref_name }} - - **Installation:** - \`\`\`bash - npm install @hackmd/api@beta - \`\`\` - - **Note:** This is a pre-release version and may contain unstable features." \ - --prerelease \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0cbd90..04e4491 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,25 @@ on: push: tags: - 'v*' + branches: + - develop + +permissions: + id-token: write # OIDC for npm trusted publishing + contents: write # draft / pre-releases via gh jobs: - publish: + publish-release: + name: Release (tag) + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v6 + - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' registry-url: 'https://registry.npmjs.org' cache: 'npm' cache-dependency-path: nodejs/package-lock.json @@ -30,20 +38,84 @@ jobs: - name: Publish to NPM working-directory: nodejs run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Extract version from tag run: | - # Extract version from tag (remove 'v' prefix) VERSION=${GITHUB_REF#refs/tags/v} echo "VERSION=$VERSION" >> $GITHUB_ENV echo "Extracted version: $VERSION" - name: Create draft release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | gh release create "$GITHUB_REF_NAME" \ - --title "Release v${{ env.VERSION }}" \ - --draft \ No newline at end of file + --title "Release v${VERSION}" \ + --draft + + publish-prerelease: + name: Pre-release (develop) + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + cache-dependency-path: nodejs/package-lock.json + + - name: Install dependencies + working-directory: nodejs + run: npm ci + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Generate pre-release version + working-directory: nodejs + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + SHORT_SHA=$(git rev-parse --short HEAD) + TIMESTAMP=$(date +%Y%m%d%H%M%S) + PRE_RELEASE_VERSION="${CURRENT_VERSION}-beta.${TIMESTAMP}.${SHORT_SHA}" + echo "Pre-release version: $PRE_RELEASE_VERSION" + echo "PRE_RELEASE_VERSION=$PRE_RELEASE_VERSION" >> $GITHUB_ENV + npm version $PRE_RELEASE_VERSION --no-git-tag-version + + - name: Build + working-directory: nodejs + run: npm run build + + - name: Publish pre-release to NPM + working-directory: nodejs + run: npm publish --tag beta --access public + + - name: Create GitHub pre-release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${PRE_RELEASE_VERSION}" \ + --title "Pre-release v${PRE_RELEASE_VERSION}" \ + --notes "🚀 **Pre-release from develop branch** + + This is an automated pre-release build from the develop branch. + + **Changes:** + - Commit: ${{ github.sha }} + - Branch: ${{ github.ref_name }} + + **Installation:** + \`\`\`bash + npm install @hackmd/api@beta + \`\`\` + + **Note:** This is a pre-release version and may contain unstable features." \ + --prerelease From 99a5117bcf81d0b720387d17fdd41f41d16ec655 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 30 Apr 2026 12:27:10 -0700 Subject: [PATCH 12/13] fix: required field for npm publihsing --- nodejs/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nodejs/package.json b/nodejs/package.json index eb0bbbc..09cd1ec 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -2,6 +2,11 @@ "name": "@hackmd/api", "version": "2.5.0", "description": "HackMD Node.js API Client", + "repository": { + "type": "git", + "url": "https://github.com/hackmdio/api-client.git", + "directory": "nodejs" + }, "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", From 38cb6cd3353214a8908914a7e28c2c3006b180b9 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Tue, 19 May 2026 12:02:08 -0700 Subject: [PATCH 13/13] chore: bump version to 2.6.0 Co-authored-by: Cursor --- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e1d877e..b797868 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hackmd/api", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@hackmd/api", - "version": "2.5.0", + "version": "2.6.0", "license": "MIT", "dependencies": { "axios": "^1.8.4", diff --git a/nodejs/package.json b/nodejs/package.json index 09cd1ec..de171e4 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "@hackmd/api", - "version": "2.5.0", + "version": "2.6.0", "description": "HackMD Node.js API Client", "repository": { "type": "git",