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/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc8e584..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,5 +38,84 @@ jobs: - name: Publish to NPM working-directory: nodejs run: npm publish --access public + + - name: Extract version from tag + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "Extracted version: $VERSION" + + - name: Create draft release env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --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 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..ec9cee7 --- /dev/null +++ b/examples/book-mode-conference/.env.example @@ -0,0 +1,49 @@ +# HackMD Conference Note Generation Environment Variables + +# 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 + +# 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 new file mode 100644 index 0000000..707a6b2 --- /dev/null +++ b/examples/book-mode-conference/README.md @@ -0,0 +1,265 @@ +# Book Mode Conference Note Generator + +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 + +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, Q&A, and discussion + +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 + +3. **Resume Functionality**: + - Saves progress automatically + - Can resume if interrupted (power outage, network issues, etc.) + - Tracks completed sessions to avoid duplicates + +## Setup + +### 1. Install Dependencies +```bash +cd /path/to/api-client/examples/book-mode-conference +npm install +``` + +### 2. Configure Environment +```bash +cp .env.example .env +# Edit .env with your settings +``` + +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 +``` + +### 3. Customize Configuration + +Edit the constants at the top of `index.ts`: + +```typescript +// HackMD announcement note to embed in each session note +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' + +// Team path where notes will be created +const TEAM_PATH = 'TechConf' + +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' +``` + +### 4. Prepare Session Data + +Ensure `sessions.json` exists with your conference session data: + +```json +[ + { + "id": "session-001", + "title": "Opening Keynote: The Future of Technology", + "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" + } +] +``` + +## Usage + +### Test Mode (Recommended First) +```bash +# Creates only 3 sessions for testing +npx tsx index.ts --test +``` + +### Production Mode +```bash +# Create all session notes +npx tsx index.ts + +# With rate limiting (recommended for large conferences) +npx tsx index.ts --delay-ms 300 +``` + +### Resume Interrupted Execution +```bash +# If the script was interrupted, resume from where it left off +npx tsx index.ts --resume + +# Resume with rate limiting +npx tsx index.ts --resume --delay-ms 500 +``` + +### All Available Options +```bash +npx tsx index.ts [options] + +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 + +### Session Notes +Each session gets a note with this structure: +```markdown +# Session Title - Speaker Name + +**Time:** 09:00 ~ 09:30 | **Room:** Main Stage + +{%hackmd @TechConf/announcement-note-id %} + +> ==ๆŠ•ๅฝฑ็‰‡== +> ๏ผˆ่ฌ›่€…่ซ‹ๅœจๆญคๆ”พ็ฝฎๆŠ•ๅฝฑ็‰‡้€ฃ็ต๏ผ‰ + +> ==Q & A== +> ๏ผˆ่ฌ›่€… Q&A ็›ธ้—œ้€ฃ็ต๏ผ‰ + +## ๐Ÿ“ ็ญ†่จ˜ๅ€ +> ่ซ‹ๅพž้€™่ฃก้–‹ๅง‹่จ˜้Œ„ไฝ ็š„็ญ†่จ˜ + +## โ“ Q&A ๅ€ๅŸŸ +> ่ฌ›่€…ๅ•็ญ”่ˆ‡็พๅ ดไบ’ๅ‹• + +## ๐Ÿ’ฌ ่จŽ่ซ–ๅ€ +> ๆญก่ฟŽๅœจๆญค้€ฒ่กŒ่จŽ่ซ–่ˆ‡ไบคๆต +``` + +### Main Book Note +The index book organizes sessions by day: +```markdown +TechConf 2025 ๅ…ฑๅŒ็ญ†่จ˜ +=== + +## ๆญก่ฟŽไพ†ๅˆฐ TechConf 2025๏ผ + +- [HackMD ๅฟซ้€Ÿๅ…ฅ้–€](https://hackmd.io/s/BJvtP4zGX) +- [HackMD ๆœƒ่ญฐๅŠŸ่ƒฝไป‹็ดน](https://hackmd.io/s/BJHWlNQMX) + +## ่ญฐ็จ‹็ญ†่จ˜ + +### 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) +``` + +## Resume Functionality + +The script automatically saves progress to `progress.json`: + +```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" +} +``` + +### When to Use Resume + +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 + +### Resume Workflow + +```bash +# 1. Start generation +npx tsx index.ts --delay-ms 300 + +# 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 +``` + +## Troubleshooting + +### Environment Variable Issues + +Test if your `.env` file is loaded correctly: +```bash +node test-env.js +``` + +### Common Errors + +**401 Authentication Error** +- Check `HACKMD_ACCESS_TOKEN` is correct +- Verify token has team permissions +- Ensure API endpoint is correct + +**"Session file not found"** +- Ensure `sessions.json` exists in same directory +- Check JSON format is valid + +**"Failed to create note"** +- Check team permissions +- Verify `TEAM_PATH` is correct +- Check API quota limits + +### Manual Override + +If `.env` isn't working: +```bash +export HACKMD_ACCESS_TOKEN=your_token_here +npx tsx index.ts --test +``` + +## Customization + +The example is designed to be easily customizable: + +### Session Note Template +Edit `generateSessionNoteContent()` function to change note structure. + +### Book Organization +Edit `generateBookContent()` function to change how sessions are grouped. + +### Excluded Sessions +Edit `EXCLUDE_SESSIONS` array to filter out non-content sessions. + +### Conference Details +Change `CONFERENCE_NAME`, `TEAM_PATH`, and `ANNOUNCEMENT_NOTE` constants. + +## Production Tips + +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. \ 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..579af29 --- /dev/null +++ b/examples/book-mode-conference/index.ts @@ -0,0 +1,655 @@ +#!/usr/bin/env tsx +/** + * 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 + * - Session data in JSON format + */ + +'use strict' + +// Load environment variables from .env file +import dotenv from 'dotenv' +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' + +// ========================================== +// 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 +// ========================================== + +// ========================================== +// CONFIGURATION - CUSTOMIZE THESE VALUES +// ========================================== + +// HackMD announcement note to embed in each session note +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' + +// Team path where notes will be created +const TEAM_PATH = 'TechConf' + +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' + +// Sessions to exclude from note generation (customize as needed) +const EXCLUDE_SESSIONS = [ + 'ๅ ฑๅˆฐๆ™‚้–“', '้–‹ๅน•', '้–‰ๅน•', 'Opening', 'Closing', 'Break', 'Lunch', 'ไผ‘ๆฏๆ™‚้–“', 'ๅˆ้ค' +] + +// ========================================== +// 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) + }) +} + +/** + * 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 => { + if (!s.session_type) return false + const title = (s.title || '').trim() + return !EXCLUDE_SESSIONS.includes(title) + }) + .map(s => { + const speakers = s.speaker.map(speaker => speaker.speaker.public_name).join('ใ€') + + return { + id: s.id, + 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, + 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)) +} + +/** + * 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} %} + +> ==ๆŠ•ๅฝฑ็‰‡== +> ๏ผˆ่ฌ›่€…่ซ‹ๅœจๆญคๆ”พ็ฝฎๆŠ•ๅฝฑ็‰‡้€ฃ็ต๏ผ‰ + +> ==Q & A== +> ๏ผˆ่ฌ›่€… Q&A ็›ธ้—œ้€ฃ็ต๏ผ‰ + +## ๐Ÿ“ ็ญ†่จ˜ๅ€ +> ่ซ‹ๅพž้€™่ฃก้–‹ๅง‹่จ˜้Œ„ไฝ ็š„็ญ†่จ˜ + + + +## โ“ Q&A ๅ€ๅŸŸ +> ่ฌ›่€…ๅ•็ญ”่ˆ‡็พๅ ดไบ’ๅ‹• + + + +## ๐Ÿ’ฌ ่จŽ่ซ–ๅ€ +> ๆญก่ฟŽๅœจๆญค้€ฒ่กŒ่จŽ่ซ–่ˆ‡ไบคๆต + + + +###### 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 + */ +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 + } +} + +/** + * Generate the main conference book content + */ +function generateMainBookContent(bookContent: string): string { + return `${CONFERENCE_NAME} ๅ…ฑๅŒ็ญ†่จ˜ +=== + +## ๆญก่ฟŽไพ†ๅˆฐ ${CONFERENCE_NAME}๏ผ + +- [HackMD ๅฟซ้€Ÿๅ…ฅ้–€](https://hackmd.io/s/BJvtP4zGX) +- [HackMD ๆœƒ่ญฐๅŠŸ่ƒฝไป‹็ดน](https://hackmd.io/s/BJHWlNQMX) + +## ่ญฐ็จ‹็ญ†่จ˜ + +${bookContent} + +###### tags: \`${CONFERENCE_NAME}\` +` +} + +// ========================================== +// MAIN EXECUTION LOGIC +// ========================================== + +// Progress tracking for resume functionality +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')) + console.log(`๐Ÿ“ Loaded progress: ${data.completedSessions?.length || 0} sessions completed`) + return data + } catch (e: any) { + console.warn(`โš ๏ธ Failed to load progress: ${e.message}`) + return null + } + } + + function initFresh(): ProgressState { + return { + completedSessions: [], + sessionNotes: {}, + startedAt: new Date().toISOString(), + } + } + + function save(progress: ProgressState) { + 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) { + 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 + } + + 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('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) + } + + // Load and process session data + console.log('๐Ÿ“‚ Loading session data...') + const sessionList = loadAndProcessSessions() + 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(PROGRESS_FILE) + let progress: ProgressState + + if (RESUME_MODE) { + 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...') + 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, + readPermission: NotePermissionRole.GUEST as any, + writePermission: NotePermissionRole.SIGNED_IN as any + } + + try { + console.log(`๐Ÿ“ Creating note for: ${data.title}`) + const note = await api.createTeamNote(TEAM_PATH, noteData) + data.noteUrl = note.shortId + + 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}`) + data.noteUrl = 'error' + pm.save(progress) + } + } + + // Final progress save + pm.save(progress) + console.log(`โœ… Session notes creation completed (${processedCount} new notes, ${skippedCount} skipped)`) + + // 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...') + + // 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) + + 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 = mainBookUrl + pm.save(progress) + + console.log(`โœ… Main book created: ${mainBookUrl}`) + } 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 }) +} + +// ========================================== +// SCRIPT EXECUTION +// ========================================== + +// Run the script when executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + 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 } diff --git a/examples/book-mode-conference/package-lock.json b/examples/book-mode-conference/package-lock.json new file mode 100644 index 0000000..62815fe --- /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.5.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..d9a7d36 --- /dev/null +++ b/examples/book-mode-conference/sessions.json @@ -0,0 +1,133 @@ +[ + { + "id": "session-001", + "title": "Opening Keynote: The Future of Technology", + "speaker": [ + { + "speaker": { + "public_name": "John Doe" + } + } + ], + "session_type": "keynote", + "started_at": "2025-03-15T09:00:00Z", + "finished_at": "2025-03-15T09:30:00Z", + "tags": ["keynote", "future", "technology"], + "classroom": { + "tw_name": "ไธป่ˆžๅฐ", + "en_name": "Main Stage" + }, + "language": "en", + "difficulty": "General" + }, + { + "id": "session-002", + "title": "Advanced Cloud Architecture", + "speaker": [ + { + "speaker": { + "public_name": "Jane Smith" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-15T10:00:00Z", + "finished_at": "2025-03-15T10:45:00Z", + "tags": ["cloud", "architecture", "scalability"], + "classroom": { + "tw_name": "Aๆœƒ่ญฐๅฎค", + "en_name": "Room A" + }, + "language": "en", + "difficulty": "Beginner" + }, + { + "id": "session-003", + "title": "Machine Learning in Production", + "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": ["ml", "ai", "production"], + "classroom": { + "tw_name": "Bๆœƒ่ญฐๅฎค", + "en_name": "Room B" + }, + "language": "en", + "difficulty": "Advanced" + }, + { + "id": "session-004", + "title": "Microservices Design Patterns", + "speaker": [ + { + "speaker": { + "public_name": "Mike Johnson" + } + } + ], + "session_type": "talk", + "started_at": "2025-03-15T14:00:00Z", + "finished_at": "2025-03-15T14:45:00Z", + "tags": ["microservices", "design", "patterns"], + "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": ["data-science", "analytics"], + "classroom": { + "tw_name": "Aๆœƒ่ญฐๅฎค", + "en_name": "Room A" + }, + "language": "zh-TW", + "difficulty": "Intermediate" + }, + { + "id": "session-006", + "title": "Cybersecurity Best Practices", + "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", "cybersecurity", "best-practices"], + "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 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" 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-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 65bc608..de171e4 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,7 +1,12 @@ { "name": "@hackmd/api", - "version": "2.5.0", + "version": "2.6.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", @@ -25,7 +30,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 e625ee5..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 } 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) || @@ -165,7 +201,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 +225,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) } @@ -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 766b292..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,12 +92,17 @@ export type Note = { readPermission: NotePermissionRole writePermission: NotePermissionRole + folderPaths?: FolderPath[] } export type SingleNote = Note & { content: string } +export type UpdateNoteOptions = Partial> & { + parentFolderId?: string +} + // User export type GetMe = User @@ -105,4 +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 74a9aaf..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 @@ -98,3 +98,107 @@ test('should throw HackMD error object', async () => { expect(error).toHaveProperty('resetAfter') } }) + +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 + + 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) +}) + +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 + } + }) + }) + }) +}) 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) + }) }) })