├── .github └── workflows │ ├── ci.yml │ ├── pre-release.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── book-mode-conference │ ├── .env.example │ ├── README.md │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ ├── sessions.json │ └── tsconfig.json └── nodejs │ ├── .env.example │ ├── README.md │ ├── index.js │ ├── package-lock.json │ └── package.json └── nodejs ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── error.ts ├── index.ts └── type.ts ├── tests ├── api.spec.ts ├── etag.spec.ts └── mock │ ├── handlers.ts │ └── index.ts ├── tsconfig.base.json ├── tsconfig.build.json └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master, develop ] 6 | pull_request: 7 | branches: [ master, develop ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | cache: 'npm' 21 | cache-dependency-path: nodejs/package-lock.json 22 | 23 | - name: Install dependencies 24 | working-directory: nodejs 25 | run: npm ci 26 | 27 | - name: Run lint 28 | working-directory: nodejs 29 | run: npm run lint 30 | 31 | - name: Build 32 | working-directory: nodejs 33 | run: npm run build 34 | 35 | - name: Run tests 36 | working-directory: nodejs 37 | env: 38 | HACKMD_ACCESS_TOKEN: "test_token_123456789" 39 | run: npm test -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | pre-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | registry-url: 'https://registry.npmjs.org' 22 | cache: 'npm' 23 | cache-dependency-path: nodejs/package-lock.json 24 | 25 | - name: Install dependencies 26 | working-directory: nodejs 27 | run: npm ci 28 | 29 | - name: Configure Git 30 | run: | 31 | git config --global user.name "github-actions[bot]" 32 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 33 | 34 | - name: Generate pre-release version 35 | working-directory: nodejs 36 | run: | 37 | # Get current version from package.json 38 | CURRENT_VERSION=$(node -p "require('./package.json').version") 39 | 40 | # Get short commit hash 41 | SHORT_SHA=$(git rev-parse --short HEAD) 42 | 43 | # Get current timestamp 44 | TIMESTAMP=$(date +%Y%m%d%H%M%S) 45 | 46 | # Create pre-release version: current-version-beta.timestamp.sha 47 | PRE_RELEASE_VERSION="${CURRENT_VERSION}-beta.${TIMESTAMP}.${SHORT_SHA}" 48 | 49 | echo "Pre-release version: $PRE_RELEASE_VERSION" 50 | echo "PRE_RELEASE_VERSION=$PRE_RELEASE_VERSION" >> $GITHUB_ENV 51 | 52 | # Update package.json with pre-release version 53 | npm version $PRE_RELEASE_VERSION --no-git-tag-version 54 | 55 | - name: Build 56 | working-directory: nodejs 57 | run: npm run build 58 | 59 | - name: Publish pre-release to NPM 60 | working-directory: nodejs 61 | run: npm publish --tag beta --access public 62 | env: 63 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | 65 | - name: Create GitHub pre-release 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | run: | 69 | gh release create "v${{ env.PRE_RELEASE_VERSION }}" \ 70 | --title "Pre-release v${{ env.PRE_RELEASE_VERSION }}" \ 71 | --notes "🚀 **Pre-release from develop branch** 72 | 73 | This is an automated pre-release build from the develop branch. 74 | 75 | **Changes:** 76 | - Commit: ${{ github.sha }} 77 | - Branch: ${{ github.ref_name }} 78 | 79 | **Installation:** 80 | \`\`\`bash 81 | npm install @hackmd/api@beta 82 | \`\`\` 83 | 84 | **Note:** This is a pre-release version and may contain unstable features." \ 85 | --prerelease -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | registry-url: 'https://registry.npmjs.org' 19 | cache: 'npm' 20 | cache-dependency-path: nodejs/package-lock.json 21 | 22 | - name: Install dependencies 23 | working-directory: nodejs 24 | run: npm ci 25 | 26 | - name: Build 27 | working-directory: nodejs 28 | run: npm run build 29 | 30 | - name: Publish to NPM 31 | working-directory: nodejs 32 | run: npm publish --access public 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | 36 | - name: Extract version from tag 37 | run: | 38 | # Extract version from tag (remove 'v' prefix) 39 | VERSION=${GITHUB_REF#refs/tags/v} 40 | echo "VERSION=$VERSION" >> $GITHUB_ENV 41 | echo "Extracted version: $VERSION" 42 | 43 | - name: Create draft release 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | run: | 47 | gh release create "$GITHUB_REF_NAME" \ 48 | --title "Release v${{ env.VERSION }}" \ 49 | --draft -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | .env.* 4 | !.env.example 5 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 HackMD 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HackMD API Clients 2 | 3 | This repository contains a set of packages for interacting with the [HackMD API](https://hackmd.io/). 4 | 5 | ## Node.JS 6 | 7 | See [README](./nodejs) 8 | 9 | ## Examples 10 | 11 | To help you get started quickly, we provide comprehensive usage examples in the `examples/` directory: 12 | 13 | ### Node.js Example 14 | 15 | The `examples/nodejs/` directory contains a complete example project demonstrating: 16 | 17 | - User information retrieval 18 | - Note creation and management 19 | - ETag support for caching 20 | - Content updates 21 | - Error handling with retry logic 22 | - Environment variable configuration 23 | 24 | To run the Node.js example: 25 | 26 | 1. Navigate to the example directory: `cd examples/nodejs` 27 | 2. Follow the setup instructions in [examples/nodejs/README.md](./examples/nodejs/README.md) 28 | 3. Set your HackMD access token 29 | 4. Run `npm start` 30 | 31 | The example includes detailed comments and demonstrates best practices for using the HackMD API client. 32 | 33 | ### Book Mode Conference Note Example 34 | 35 | The `examples/book-mode-conference/` directory contains a TypeScript example for creating a "book mode" conference note system: 36 | 37 | - **Book Mode Notes**: Creates a master note that links to all session notes 38 | - **Bulk Note Creation**: Automatically creates individual notes for each conference session 39 | - **TypeScript Implementation**: Full type safety with tsx support for direct execution 40 | - **Configurable Templates**: Customizable note templates and conference settings 41 | - **Hierarchical Organization**: Sessions organized by day and time in the main book 42 | - **Error Handling**: Graceful handling of API failures during bulk operations 43 | 44 | To run the book mode conference example: 45 | 46 | 1. Navigate to the example directory: `cd examples/book-mode-conference` 47 | 2. Follow the setup instructions in [examples/book-mode-conference/README.md](./examples/book-mode-conference/README.md) 48 | 3. Customize the configuration constants and session data 49 | 4. Set your HackMD access token 50 | 5. Run `npm start` 51 | 52 | This example demonstrates advanced usage patterns including bulk operations, team note management, and creating interconnected note structures for conferences or events. 53 | 54 | ## LICENSE 55 | 56 | MIT 57 | -------------------------------------------------------------------------------- /examples/book-mode-conference/.env.example: -------------------------------------------------------------------------------- 1 | # HackMD API Configuration 2 | # Get your access token from: https://hackmd.io/@hackmd-api/developer-portal 3 | 4 | # Required: Your HackMD access token 5 | HACKMD_ACCESS_TOKEN=your_access_token_here 6 | 7 | # Optional: HackMD API endpoint (defaults to https://api.hackmd.io/v1) 8 | HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 -------------------------------------------------------------------------------- /examples/book-mode-conference/README.md: -------------------------------------------------------------------------------- 1 | # Book Mode Conference Note Generator 2 | 3 | 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. 4 | 5 | ## What This Example Does 6 | 7 | The script performs the following actions: 8 | 9 | 1. **Loads Session Data**: Reads conference session information from `sessions.json` 10 | 2. **Creates Individual Session Notes**: For each session, creates a dedicated HackMD note with: 11 | - Session title and speaker information 12 | - Embedded announcement note 13 | - Sections for notes, discussion, and related links 14 | - Appropriate tags and permissions 15 | 3. **Generates Main Book Note**: Creates a master note that: 16 | - Contains welcome information and useful links 17 | - Organizes all session notes by day and time 18 | - Provides easy navigation to all sessions 19 | - Serves as a central hub for the conference 20 | 21 | ## Features 22 | 23 | - **TypeScript Implementation**: Written in TypeScript with full type safety 24 | - **Configurable Constants**: All configuration is centralized at the top of the file 25 | - **Comprehensive Comments**: Well-documented code explaining each section 26 | - **Error Handling**: Graceful handling of API failures 27 | - **tsx Support**: Can be run directly without compilation using tsx 28 | - **Modular Design**: Functions are exportable for potential reuse 29 | - **Flexible Session Data**: Supports various session types and multilingual content 30 | 31 | ## Setup 32 | 33 | ### Prerequisites 34 | 35 | - Node.js (version 16 or higher) 36 | - A HackMD account with API access 37 | - Access to a HackMD team (for creating team notes) 38 | 39 | ### Installation 40 | 41 | 1. **Build the main HackMD API package** (if not already done): 42 | ```bash 43 | cd ../../nodejs 44 | npm install 45 | npm run build 46 | cd ../examples/book-mode-conference 47 | ``` 48 | 49 | 2. **Install dependencies**: 50 | ```bash 51 | npm install 52 | ``` 53 | 54 | 3. **Configure your HackMD access token**: 55 | 56 | **Option A: Environment Variable** 57 | ```bash 58 | # For Unix/Linux/macOS 59 | export HACKMD_ACCESS_TOKEN=your_access_token_here 60 | 61 | # For Windows PowerShell 62 | $env:HACKMD_ACCESS_TOKEN="your_access_token_here" 63 | ``` 64 | 65 | **Option B: .env File** 66 | ```bash 67 | cp .env.example .env 68 | # Edit .env and add your access token 69 | ``` 70 | 71 | You can get your access token from the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). 72 | 73 | ### Configuration 74 | 75 | Before running the script, you may want to customize the configuration constants at the top of `index.ts`: 76 | 77 | #### Essential Configuration 78 | 79 | ```typescript 80 | // HackMD announcement note to embed in each session note 81 | const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' 82 | 83 | // Team path where notes will be created 84 | const TEAM_PATH = 'DevOpsDay' 85 | 86 | // Conference details 87 | const CONFERENCE_CONFIG = { 88 | name: 'DevOpsDays Taipei 2025', 89 | website: 'https://devopsdays.tw/', 90 | community: 'https://www.facebook.com/groups/DevOpsTaiwan/', 91 | tags: 'DevOpsDays Taipei 2025' 92 | } 93 | ``` 94 | 95 | #### Session Data Format 96 | 97 | The script expects session data in `sessions.json` with the following structure: 98 | 99 | ```json 100 | [ 101 | { 102 | "id": "session-001", 103 | "title": "Session Title", 104 | "speaker": [ 105 | { 106 | "speaker": { 107 | "public_name": "Speaker Name" 108 | } 109 | } 110 | ], 111 | "session_type": "talk", 112 | "started_at": "2025-03-15T09:00:00Z", 113 | "finished_at": "2025-03-15T09:30:00Z", 114 | "tags": ["tag1", "tag2"], 115 | "classroom": { 116 | "tw_name": "會議室名稱", 117 | "en_name": "Room Name" 118 | }, 119 | "language": "en", 120 | "difficulty": "General" 121 | } 122 | ] 123 | ``` 124 | 125 | ## Running the Example 126 | 127 | ### Development Mode (with file watching) 128 | ```bash 129 | npm run dev 130 | ``` 131 | 132 | ### Production Mode 133 | ```bash 134 | npm start 135 | ``` 136 | 137 | ### Direct Execution with tsx 138 | ```bash 139 | npx tsx index.ts 140 | ``` 141 | 142 | ## Sample Session Data 143 | 144 | The included `sessions.json` contains sample conference session data with: 145 | 146 | - **Multiple session types**: keynotes, talks, workshops 147 | - **Multi-day schedule**: Sessions across different days 148 | - **Bilingual support**: English and Traditional Chinese sessions 149 | - **Various difficulty levels**: General, Beginner, Intermediate, Advanced 150 | - **Multiple speakers**: Examples of single and multiple speaker sessions 151 | 152 | ## Generated Output 153 | 154 | The script will create: 155 | 156 | 1. **Individual Session Notes**: Each with a dedicated HackMD note containing: 157 | - Session title with speaker names 158 | - Embedded announcement note 159 | - Sections for collaborative note-taking 160 | - Discussion area 161 | - Related links 162 | 163 | 2. **Main Conference Book**: A master note containing: 164 | - Conference welcome information 165 | - Organized schedule with links to all session notes 166 | - Quick navigation by day and time 167 | - Useful conference resources 168 | 169 | ### Example Output 170 | 171 | ``` 172 | === Creating Individual Session Notes === 173 | ✓ Created note for: Welcome to DevOpsDays - John Doe 174 | ✓ Created note for: Introduction to CI/CD - Jane Smith 175 | ✓ Created note for: Advanced Kubernetes Operations - Alex Chen & Sarah Wilson 176 | ... 177 | 178 | === Session URLs === 179 | [ 180 | { 181 | "id": "session-001", 182 | "url": "https://hackmd.io/abc123", 183 | "title": "Welcome to DevOpsDays - John Doe" 184 | }, 185 | ... 186 | ] 187 | 188 | === Main Conference Book Created === 189 | ✓ Book URL: https://hackmd.io/xyz789 190 | 🎉 Book mode conference notes created successfully! 191 | 📚 Main book contains links to 6 session notes 192 | ``` 193 | 194 | ## Customization 195 | 196 | ### Modifying Note Templates 197 | 198 | You can customize the session note template by modifying the `generateSessionNoteContent` function: 199 | 200 | ```typescript 201 | function generateSessionNoteContent(session: ProcessedSession): string { 202 | return `# ${session.title} 203 | 204 | {%hackmd ${ANNOUNCEMENT_NOTE} %} 205 | 206 | ## Your Custom Section 207 | > Add your custom content here 208 | 209 | ## ${SESSION_NOTE_CONFIG.sections.notes} 210 | > ${SESSION_NOTE_CONFIG.sections.notesDescription} 211 | 212 | // ... rest of template 213 | ` 214 | } 215 | ``` 216 | 217 | ### Changing the Book Structure 218 | 219 | The book organization can be modified by changing the nesting keys in the main function: 220 | 221 | ```typescript 222 | // Current: organize by day, then by start time 223 | const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) 224 | 225 | // Alternative: organize by session type, then by day 226 | const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['sessionType', 'day']) 227 | ``` 228 | 229 | ### Adding Additional Metadata 230 | 231 | You can extend the session data structure and processing by: 232 | 233 | 1. Adding new fields to the `ProcessedSession` interface 234 | 2. Updating the `loadAndProcessSessions` function to process new fields 235 | 3. Modifying the note templates to include the new information 236 | 237 | ## Error Handling 238 | 239 | The script includes comprehensive error handling: 240 | 241 | - **Missing Environment Variables**: Clear error messages with setup instructions 242 | - **Missing Session File**: Helpful error message with expected file location 243 | - **API Failures**: Individual session note failures don't stop the entire process 244 | - **Network Issues**: The HackMD API client includes built-in retry logic 245 | 246 | ## Troubleshooting 247 | 248 | ### Common Issues 249 | 250 | **"HACKMD_ACCESS_TOKEN environment variable is not set"** 251 | - Solution: Set your access token using one of the methods in the Setup section 252 | 253 | **"Sessions file not found"** 254 | - Solution: Ensure `sessions.json` exists in the same directory as `index.ts` 255 | 256 | **"Failed to create note for [session]"** 257 | - Check your team permissions 258 | - Verify the team path is correct 259 | - Ensure your access token has team note creation permissions 260 | 261 | **"Failed to create main book"** 262 | - Same troubleshooting steps as individual notes 263 | - Check that you have sufficient API quota remaining 264 | 265 | ### Development Tips 266 | 267 | 1. **Start Small**: Test with a few sessions first by modifying `sessions.json` 268 | 2. **Check Permissions**: Ensure your HackMD team allows note creation 269 | 3. **Monitor Rate Limits**: The script includes built-in retry logic, but be mindful of API limits 270 | 4. **Backup Data**: Consider backing up important notes before running the script 271 | 272 | ## API Features Demonstrated 273 | 274 | This example showcases several HackMD API features: 275 | 276 | - **Team Note Creation**: Creating notes within a team context 277 | - **Permission Management**: Setting read/write permissions for notes 278 | - **Content Templates**: Using consistent note structures 279 | - **Bulk Operations**: Creating multiple notes programmatically 280 | - **Error Handling**: Graceful handling of API errors 281 | 282 | ## License 283 | 284 | This example is part of the HackMD API client and is licensed under the MIT License. 285 | 286 | ## Contributing 287 | 288 | If you have suggestions for improving this example or find bugs, please open an issue or submit a pull request to the main repository. -------------------------------------------------------------------------------- /examples/book-mode-conference/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | /** 3 | * Book Mode Conference Note Generator 4 | * 5 | * This script generates a "book mode" conference note system using HackMD API. 6 | * It creates individual notes for each session and a main book note that links to all sessions. 7 | * 8 | * Book mode is a Markdown note that contains organized links to each session note page, 9 | * making it easy for conference attendees to navigate between different session notes. 10 | * 11 | * Prerequisites: 12 | * - HackMD access token (set in HACKMD_ACCESS_TOKEN environment variable) 13 | * - Team path where notes will be created 14 | * - Session data in JSON format 15 | */ 16 | 17 | 'use strict' 18 | 19 | // Load environment variables from .env file in project root 20 | import dotenv from 'dotenv' 21 | dotenv.config() 22 | 23 | import _ from 'lodash' 24 | import moment from 'moment' 25 | import { API } from '@hackmd/api' 26 | import fs from 'fs' 27 | import path from 'path' 28 | import { fileURLToPath } from 'url' 29 | 30 | // Get the current directory for ES modules 31 | const __filename = fileURLToPath(import.meta.url) 32 | const __dirname = path.dirname(__filename) 33 | 34 | // ========================================== 35 | // CONFIGURATION CONSTANTS 36 | // ========================================== 37 | 38 | /** 39 | * HackMD announcement note short ID to be embedded in each session note 40 | * This note typically contains conference-wide announcements or information 41 | */ 42 | const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' 43 | 44 | /** 45 | * Team path where all notes will be created 46 | * This should be your HackMD team's unique identifier 47 | */ 48 | const TEAM_PATH = 'DevOpsDay' 49 | 50 | /** 51 | * Conference details for the main book note 52 | */ 53 | const CONFERENCE_CONFIG = { 54 | name: 'DevOpsDays Taipei 2025', 55 | website: 'https://devopsdays.tw/', 56 | community: 'https://www.facebook.com/groups/DevOpsTaiwan/', 57 | tags: 'DevOpsDays Taipei 2025' 58 | } 59 | 60 | /** 61 | * Session note template configuration 62 | */ 63 | const SESSION_NOTE_CONFIG = { 64 | // Default content sections for each session note 65 | sections: { 66 | notes: '筆記區', 67 | notesDescription: '從這開始記錄你的筆記', 68 | discussion: '討論區', 69 | discussionDescription: '歡迎在此進行討論', 70 | links: '相關連結' 71 | } 72 | } 73 | 74 | /** 75 | * Main book note configuration 76 | */ 77 | const BOOK_NOTE_CONFIG = { 78 | welcomeNote: '/@DevOpsDay/ry9DnJIfel', 79 | hackmdQuickStart: 'https://hackmd.io/s/BJvtP4zGX', 80 | hackmdMeetingFeatures: 'https://hackmd.io/s/BJHWlNQMX' 81 | } 82 | 83 | // ========================================== 84 | // TYPE DEFINITIONS 85 | // ========================================== 86 | 87 | /** 88 | * Define permission constants (equivalent to the API enums) 89 | * These mirror the NotePermissionRole enum from the API 90 | */ 91 | const NotePermissionRole = { 92 | OWNER: 'owner', 93 | SIGNED_IN: 'signed_in', 94 | GUEST: 'guest' 95 | } as const 96 | 97 | type NotePermissionRoleType = typeof NotePermissionRole[keyof typeof NotePermissionRole] 98 | 99 | /** 100 | * Raw session data structure from JSON file 101 | */ 102 | interface RawSession { 103 | id: string 104 | title: string 105 | speaker: Array<{ 106 | speaker: { 107 | public_name: string 108 | } 109 | }> 110 | session_type: string | null 111 | started_at: string 112 | finished_at: string 113 | tags?: string[] 114 | classroom?: { 115 | tw_name?: string 116 | en_name?: string 117 | } 118 | language?: string 119 | difficulty?: string 120 | } 121 | 122 | /** 123 | * Processed session data structure 124 | */ 125 | interface ProcessedSession { 126 | id: string 127 | title: string 128 | tags: string[] 129 | startDate: number 130 | day: string 131 | startTime: string 132 | endTime: string 133 | sessionType: string 134 | classroom: string 135 | language: string 136 | difficulty: string 137 | noteUrl?: string 138 | } 139 | 140 | /** 141 | * Session URL reference for output 142 | */ 143 | interface SessionUrl { 144 | id: string 145 | url: string 146 | title: string 147 | } 148 | 149 | // ========================================== 150 | // UTILITY FUNCTIONS 151 | // ========================================== 152 | 153 | /** 154 | * Creates a nested object structure from an array using specified keys 155 | * This is used to organize sessions by day and time for the book structure 156 | * 157 | * @param seq - Array of items to nest 158 | * @param keys - Array of property names to use for nesting levels 159 | * @returns Nested object structure 160 | */ 161 | function nest(seq: any[], keys: string[]): any { 162 | if (!keys.length) return seq 163 | 164 | const [first, ...rest] = keys 165 | return _.mapValues(_.groupBy(seq, first), function (value) { 166 | return nest(value, rest) 167 | }) 168 | } 169 | 170 | /** 171 | * Extracts the HackMD host URL from the API endpoint 172 | * This is used to generate correct note URLs for display 173 | * 174 | * @returns The HackMD host URL 175 | */ 176 | function getHackMDHost(): string { 177 | const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' 178 | try { 179 | const url = new URL(apiEndpoint) 180 | return `${url.protocol}//${url.host}` 181 | } catch (error) { 182 | console.warn('Failed to parse HACKMD_API_ENDPOINT, falling back to https://hackmd.io') 183 | return 'https://hackmd.io' 184 | } 185 | } 186 | 187 | /** 188 | * Loads and processes session data from JSON file 189 | * Filters out sessions with null session types and enriches data 190 | * 191 | * @returns Array of processed session data 192 | */ 193 | function loadAndProcessSessions(): ProcessedSession[] { 194 | const sessionsPath = path.join(__dirname, 'sessions.json') 195 | 196 | if (!fs.existsSync(sessionsPath)) { 197 | throw new Error(`Sessions file not found: ${sessionsPath}`) 198 | } 199 | 200 | const rawSessions: RawSession[] = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')) 201 | 202 | return rawSessions 203 | .filter(s => s.session_type && s.session_type !== null) // Filter out null session types 204 | .map(s => { 205 | // Combine speaker names with ampersand separator 206 | const speakers = s.speaker.map(speaker => { 207 | return speaker.speaker.public_name 208 | }).join(' & ') 209 | 210 | return { 211 | id: s.id, 212 | title: s.title + (speakers ? " - " + speakers : ""), 213 | tags: s.tags || [], 214 | startDate: moment(s.started_at).valueOf(), 215 | day: moment(s.started_at).format('MM/DD'), 216 | startTime: moment(s.started_at).format('HH:mm'), 217 | endTime: moment(s.finished_at).format('HH:mm'), 218 | sessionType: s.session_type!, // We already filtered out null values above 219 | classroom: s.classroom?.tw_name || s.classroom?.en_name || 'TBD', 220 | language: s.language || 'en', 221 | difficulty: s.difficulty || 'General' 222 | } 223 | }) 224 | .sort((a, b) => (a.startDate - b.startDate)) // Sort by start time 225 | } 226 | 227 | /** 228 | * Generates the content for a session note 229 | * 230 | * @param session - The session data 231 | * @returns Formatted markdown content for the session note 232 | */ 233 | function generateSessionNoteContent(session: ProcessedSession): string { 234 | return `# ${session.title} 235 | 236 | {%hackmd ${ANNOUNCEMENT_NOTE} %} 237 | 238 | ## ${SESSION_NOTE_CONFIG.sections.notes} 239 | > ${SESSION_NOTE_CONFIG.sections.notesDescription} 240 | 241 | ## ${SESSION_NOTE_CONFIG.sections.discussion} 242 | > ${SESSION_NOTE_CONFIG.sections.discussionDescription} 243 | 244 | ## ${SESSION_NOTE_CONFIG.sections.links} 245 | - [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) 246 | 247 | ###### tags: \`${CONFERENCE_CONFIG.tags}\` 248 | ` 249 | } 250 | 251 | /** 252 | * Generates the hierarchical book content from nested session data 253 | * 254 | * @param sessions - Nested session data organized by day/time 255 | * @param layer - Current nesting level (for header depth) 256 | * @returns Formatted markdown content for the book section 257 | */ 258 | function generateBookContent(sessions: any, layer: number): string { 259 | const days = Object.keys(sessions).sort() 260 | let content = "" 261 | 262 | if (Array.isArray(sessions[days[0]])) { 263 | // This is the leaf level (sessions) - flatten all sessions and sort chronologically 264 | let allSessions: ProcessedSession[] = [] 265 | for (let timeSlot of days) { 266 | allSessions = allSessions.concat(sessions[timeSlot]) 267 | } 268 | // Sort all sessions by start time 269 | const sortedSessions = _.sortBy(allSessions, ['startTime']) 270 | 271 | for (let session of sortedSessions) { 272 | if (session.noteUrl && session.noteUrl !== 'error') { 273 | content += `- ${session.startTime} ~ ${session.endTime} [${session.title}](/${session.noteUrl}) (${session.classroom})\n` 274 | } 275 | } 276 | return content 277 | } else { 278 | // This is a grouping level 279 | for (let day of days) { 280 | content += `${new Array(layer).fill("#").join("")} ${day}\n\n` 281 | content += generateBookContent(sessions[day], layer + 1) 282 | } 283 | return content 284 | } 285 | } 286 | 287 | /** 288 | * Generates the main conference book note content 289 | * 290 | * @param bookContent - The hierarchical session content 291 | * @returns Formatted markdown content for the main book note 292 | */ 293 | function generateMainBookContent(bookContent: string): string { 294 | return `${CONFERENCE_CONFIG.name} 共同筆記 295 | === 296 | 297 | ## 歡迎來到 ${CONFERENCE_CONFIG.name}! 298 | 299 | - [歡迎來到 DevOpsDays!](${BOOK_NOTE_CONFIG.welcomeNote}) 300 | - [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) [target=_blank] 301 | - [HackMD 快速入門](${BOOK_NOTE_CONFIG.hackmdQuickStart}) 302 | - [HackMD 會議功能介紹](${BOOK_NOTE_CONFIG.hackmdMeetingFeatures}) 303 | 304 | ## 議程筆記 305 | 306 | ${bookContent} 307 | 308 | ## 相關資源 309 | 310 | - [DevOps Taiwan Community](${CONFERENCE_CONFIG.community}) 311 | - [活動照片分享區](#) 312 | - [問題回饋](#) 313 | 314 | ###### tags: \`${CONFERENCE_CONFIG.tags}\` 315 | ` 316 | } 317 | 318 | // ========================================== 319 | // MAIN EXECUTION LOGIC 320 | // ========================================== 321 | 322 | /** 323 | * Main function that orchestrates the entire book mode note creation process 324 | */ 325 | async function main(): Promise { 326 | // Validate required environment variables 327 | if (!process.env.HACKMD_ACCESS_TOKEN) { 328 | console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.') 329 | console.error('Please set your HackMD access token using one of these methods:') 330 | console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here') 331 | console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here') 332 | process.exit(1) 333 | } 334 | 335 | // Initialize API client 336 | const api = new API(process.env.HACKMD_ACCESS_TOKEN, process.env.HACKMD_API_ENDPOINT) 337 | 338 | // Load and process session data 339 | console.log('Loading session data...') 340 | const sessionList = loadAndProcessSessions() 341 | console.log(`Processing ${sessionList.length} sessions...`) 342 | 343 | // Create individual session notes 344 | console.log('\n=== Creating Individual Session Notes ===') 345 | for (let data of sessionList) { 346 | const noteContent = generateSessionNoteContent(data) 347 | 348 | const noteData = { 349 | title: data.title, 350 | content: noteContent, 351 | readPermission: NotePermissionRole.GUEST as any, 352 | writePermission: NotePermissionRole.SIGNED_IN as any 353 | } 354 | 355 | try { 356 | const note = await api.createTeamNote(TEAM_PATH, noteData) 357 | data.noteUrl = note.shortId 358 | console.log(`✓ Created note for: ${data.title}`) 359 | } catch (error: any) { 360 | console.error(`✗ Failed to create note for ${data.title}:`, error.message) 361 | data.noteUrl = 'error' 362 | } 363 | } 364 | 365 | // Output session URLs for reference 366 | const hackmdHost = getHackMDHost() 367 | const sessionUrls: SessionUrl[] = sessionList 368 | .filter(s => s.noteUrl !== 'error') 369 | .map(s => ({ 370 | id: s.id, 371 | url: `${hackmdHost}/${s.noteUrl}`, 372 | title: s.title 373 | })) 374 | 375 | console.log('\n=== Session URLs ===') 376 | console.log(JSON.stringify(sessionUrls, null, 2)) 377 | 378 | // Create nested structure for the main book 379 | const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) 380 | const bookContent = generateBookContent(nestedSessions, 1) 381 | 382 | // Create main conference book 383 | console.log('\n=== Creating Main Conference Book ===') 384 | const mainBookContent = generateMainBookContent(bookContent) 385 | 386 | try { 387 | const mainBook = await api.createTeamNote(TEAM_PATH, { 388 | title: `${CONFERENCE_CONFIG.name} 共同筆記`, 389 | content: mainBookContent, 390 | readPermission: NotePermissionRole.GUEST as any, 391 | writePermission: NotePermissionRole.SIGNED_IN as any 392 | }) 393 | 394 | console.log('\n=== Main Conference Book Created ===') 395 | console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) 396 | console.log('\n🎉 Book mode conference notes created successfully!') 397 | console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) 398 | } catch (error: any) { 399 | console.error('✗ Failed to create main book:', error.message) 400 | } 401 | } 402 | 403 | // ========================================== 404 | // SCRIPT EXECUTION 405 | // ========================================== 406 | 407 | // Run the script when executed directly 408 | if (import.meta.url === `file://${process.argv[1]}`) { 409 | main().catch(console.error) 410 | } 411 | 412 | // Export functions for potential module usage 413 | export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } -------------------------------------------------------------------------------- /examples/book-mode-conference/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-book-mode-conference-example", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "hackmd-api-book-mode-conference-example", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "@hackmd/api": "file:../../nodejs", 12 | "dotenv": "^16.4.5", 13 | "lodash": "^4.17.21", 14 | "moment": "^2.29.4" 15 | }, 16 | "devDependencies": { 17 | "@types/lodash": "^4.14.202", 18 | "@types/node": "^20.10.6", 19 | "tsx": "^4.7.0", 20 | "typescript": "^5.3.3" 21 | } 22 | }, 23 | "../../nodejs": { 24 | "name": "@hackmd/api", 25 | "version": "2.4.0", 26 | "license": "MIT", 27 | "dependencies": { 28 | "axios": "^1.8.4", 29 | "tslib": "^1.14.1" 30 | }, 31 | "devDependencies": { 32 | "@faker-js/faker": "^7.6.0", 33 | "@rollup/plugin-commonjs": "^28.0.3", 34 | "@rollup/plugin-node-resolve": "^16.0.1", 35 | "@rollup/plugin-typescript": "^12.1.2", 36 | "@types/eslint": "^8.21.0", 37 | "@types/jest": "^29.4.0", 38 | "@types/node": "^13.11.1", 39 | "@typescript-eslint/eslint-plugin": "^6.21.0", 40 | "@typescript-eslint/parser": "^6.21.0", 41 | "dotenv": "^16.0.3", 42 | "eslint": "^8.57.1", 43 | "jest": "^29.4.2", 44 | "msw": "^2.7.3", 45 | "rimraf": "^4.1.2", 46 | "rollup": "^4.41.1", 47 | "ts-jest": "^29.0.5", 48 | "ts-node": "^8.8.2", 49 | "typescript": "^4.9.5" 50 | } 51 | }, 52 | "node_modules/@esbuild/aix-ppc64": { 53 | "version": "0.25.5", 54 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", 55 | "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", 56 | "cpu": [ 57 | "ppc64" 58 | ], 59 | "dev": true, 60 | "license": "MIT", 61 | "optional": true, 62 | "os": [ 63 | "aix" 64 | ], 65 | "engines": { 66 | "node": ">=18" 67 | } 68 | }, 69 | "node_modules/@esbuild/android-arm": { 70 | "version": "0.25.5", 71 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", 72 | "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", 73 | "cpu": [ 74 | "arm" 75 | ], 76 | "dev": true, 77 | "license": "MIT", 78 | "optional": true, 79 | "os": [ 80 | "android" 81 | ], 82 | "engines": { 83 | "node": ">=18" 84 | } 85 | }, 86 | "node_modules/@esbuild/android-arm64": { 87 | "version": "0.25.5", 88 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", 89 | "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", 90 | "cpu": [ 91 | "arm64" 92 | ], 93 | "dev": true, 94 | "license": "MIT", 95 | "optional": true, 96 | "os": [ 97 | "android" 98 | ], 99 | "engines": { 100 | "node": ">=18" 101 | } 102 | }, 103 | "node_modules/@esbuild/android-x64": { 104 | "version": "0.25.5", 105 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", 106 | "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", 107 | "cpu": [ 108 | "x64" 109 | ], 110 | "dev": true, 111 | "license": "MIT", 112 | "optional": true, 113 | "os": [ 114 | "android" 115 | ], 116 | "engines": { 117 | "node": ">=18" 118 | } 119 | }, 120 | "node_modules/@esbuild/darwin-arm64": { 121 | "version": "0.25.5", 122 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", 123 | "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", 124 | "cpu": [ 125 | "arm64" 126 | ], 127 | "dev": true, 128 | "license": "MIT", 129 | "optional": true, 130 | "os": [ 131 | "darwin" 132 | ], 133 | "engines": { 134 | "node": ">=18" 135 | } 136 | }, 137 | "node_modules/@esbuild/darwin-x64": { 138 | "version": "0.25.5", 139 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", 140 | "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", 141 | "cpu": [ 142 | "x64" 143 | ], 144 | "dev": true, 145 | "license": "MIT", 146 | "optional": true, 147 | "os": [ 148 | "darwin" 149 | ], 150 | "engines": { 151 | "node": ">=18" 152 | } 153 | }, 154 | "node_modules/@esbuild/freebsd-arm64": { 155 | "version": "0.25.5", 156 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", 157 | "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", 158 | "cpu": [ 159 | "arm64" 160 | ], 161 | "dev": true, 162 | "license": "MIT", 163 | "optional": true, 164 | "os": [ 165 | "freebsd" 166 | ], 167 | "engines": { 168 | "node": ">=18" 169 | } 170 | }, 171 | "node_modules/@esbuild/freebsd-x64": { 172 | "version": "0.25.5", 173 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", 174 | "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", 175 | "cpu": [ 176 | "x64" 177 | ], 178 | "dev": true, 179 | "license": "MIT", 180 | "optional": true, 181 | "os": [ 182 | "freebsd" 183 | ], 184 | "engines": { 185 | "node": ">=18" 186 | } 187 | }, 188 | "node_modules/@esbuild/linux-arm": { 189 | "version": "0.25.5", 190 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", 191 | "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", 192 | "cpu": [ 193 | "arm" 194 | ], 195 | "dev": true, 196 | "license": "MIT", 197 | "optional": true, 198 | "os": [ 199 | "linux" 200 | ], 201 | "engines": { 202 | "node": ">=18" 203 | } 204 | }, 205 | "node_modules/@esbuild/linux-arm64": { 206 | "version": "0.25.5", 207 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", 208 | "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", 209 | "cpu": [ 210 | "arm64" 211 | ], 212 | "dev": true, 213 | "license": "MIT", 214 | "optional": true, 215 | "os": [ 216 | "linux" 217 | ], 218 | "engines": { 219 | "node": ">=18" 220 | } 221 | }, 222 | "node_modules/@esbuild/linux-ia32": { 223 | "version": "0.25.5", 224 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", 225 | "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", 226 | "cpu": [ 227 | "ia32" 228 | ], 229 | "dev": true, 230 | "license": "MIT", 231 | "optional": true, 232 | "os": [ 233 | "linux" 234 | ], 235 | "engines": { 236 | "node": ">=18" 237 | } 238 | }, 239 | "node_modules/@esbuild/linux-loong64": { 240 | "version": "0.25.5", 241 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", 242 | "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", 243 | "cpu": [ 244 | "loong64" 245 | ], 246 | "dev": true, 247 | "license": "MIT", 248 | "optional": true, 249 | "os": [ 250 | "linux" 251 | ], 252 | "engines": { 253 | "node": ">=18" 254 | } 255 | }, 256 | "node_modules/@esbuild/linux-mips64el": { 257 | "version": "0.25.5", 258 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", 259 | "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", 260 | "cpu": [ 261 | "mips64el" 262 | ], 263 | "dev": true, 264 | "license": "MIT", 265 | "optional": true, 266 | "os": [ 267 | "linux" 268 | ], 269 | "engines": { 270 | "node": ">=18" 271 | } 272 | }, 273 | "node_modules/@esbuild/linux-ppc64": { 274 | "version": "0.25.5", 275 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", 276 | "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", 277 | "cpu": [ 278 | "ppc64" 279 | ], 280 | "dev": true, 281 | "license": "MIT", 282 | "optional": true, 283 | "os": [ 284 | "linux" 285 | ], 286 | "engines": { 287 | "node": ">=18" 288 | } 289 | }, 290 | "node_modules/@esbuild/linux-riscv64": { 291 | "version": "0.25.5", 292 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", 293 | "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", 294 | "cpu": [ 295 | "riscv64" 296 | ], 297 | "dev": true, 298 | "license": "MIT", 299 | "optional": true, 300 | "os": [ 301 | "linux" 302 | ], 303 | "engines": { 304 | "node": ">=18" 305 | } 306 | }, 307 | "node_modules/@esbuild/linux-s390x": { 308 | "version": "0.25.5", 309 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", 310 | "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", 311 | "cpu": [ 312 | "s390x" 313 | ], 314 | "dev": true, 315 | "license": "MIT", 316 | "optional": true, 317 | "os": [ 318 | "linux" 319 | ], 320 | "engines": { 321 | "node": ">=18" 322 | } 323 | }, 324 | "node_modules/@esbuild/linux-x64": { 325 | "version": "0.25.5", 326 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", 327 | "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", 328 | "cpu": [ 329 | "x64" 330 | ], 331 | "dev": true, 332 | "license": "MIT", 333 | "optional": true, 334 | "os": [ 335 | "linux" 336 | ], 337 | "engines": { 338 | "node": ">=18" 339 | } 340 | }, 341 | "node_modules/@esbuild/netbsd-arm64": { 342 | "version": "0.25.5", 343 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", 344 | "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", 345 | "cpu": [ 346 | "arm64" 347 | ], 348 | "dev": true, 349 | "license": "MIT", 350 | "optional": true, 351 | "os": [ 352 | "netbsd" 353 | ], 354 | "engines": { 355 | "node": ">=18" 356 | } 357 | }, 358 | "node_modules/@esbuild/netbsd-x64": { 359 | "version": "0.25.5", 360 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", 361 | "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", 362 | "cpu": [ 363 | "x64" 364 | ], 365 | "dev": true, 366 | "license": "MIT", 367 | "optional": true, 368 | "os": [ 369 | "netbsd" 370 | ], 371 | "engines": { 372 | "node": ">=18" 373 | } 374 | }, 375 | "node_modules/@esbuild/openbsd-arm64": { 376 | "version": "0.25.5", 377 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", 378 | "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", 379 | "cpu": [ 380 | "arm64" 381 | ], 382 | "dev": true, 383 | "license": "MIT", 384 | "optional": true, 385 | "os": [ 386 | "openbsd" 387 | ], 388 | "engines": { 389 | "node": ">=18" 390 | } 391 | }, 392 | "node_modules/@esbuild/openbsd-x64": { 393 | "version": "0.25.5", 394 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", 395 | "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", 396 | "cpu": [ 397 | "x64" 398 | ], 399 | "dev": true, 400 | "license": "MIT", 401 | "optional": true, 402 | "os": [ 403 | "openbsd" 404 | ], 405 | "engines": { 406 | "node": ">=18" 407 | } 408 | }, 409 | "node_modules/@esbuild/sunos-x64": { 410 | "version": "0.25.5", 411 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", 412 | "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", 413 | "cpu": [ 414 | "x64" 415 | ], 416 | "dev": true, 417 | "license": "MIT", 418 | "optional": true, 419 | "os": [ 420 | "sunos" 421 | ], 422 | "engines": { 423 | "node": ">=18" 424 | } 425 | }, 426 | "node_modules/@esbuild/win32-arm64": { 427 | "version": "0.25.5", 428 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", 429 | "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", 430 | "cpu": [ 431 | "arm64" 432 | ], 433 | "dev": true, 434 | "license": "MIT", 435 | "optional": true, 436 | "os": [ 437 | "win32" 438 | ], 439 | "engines": { 440 | "node": ">=18" 441 | } 442 | }, 443 | "node_modules/@esbuild/win32-ia32": { 444 | "version": "0.25.5", 445 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", 446 | "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", 447 | "cpu": [ 448 | "ia32" 449 | ], 450 | "dev": true, 451 | "license": "MIT", 452 | "optional": true, 453 | "os": [ 454 | "win32" 455 | ], 456 | "engines": { 457 | "node": ">=18" 458 | } 459 | }, 460 | "node_modules/@esbuild/win32-x64": { 461 | "version": "0.25.5", 462 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", 463 | "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", 464 | "cpu": [ 465 | "x64" 466 | ], 467 | "dev": true, 468 | "license": "MIT", 469 | "optional": true, 470 | "os": [ 471 | "win32" 472 | ], 473 | "engines": { 474 | "node": ">=18" 475 | } 476 | }, 477 | "node_modules/@hackmd/api": { 478 | "resolved": "../../nodejs", 479 | "link": true 480 | }, 481 | "node_modules/@types/lodash": { 482 | "version": "4.17.18", 483 | "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", 484 | "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==", 485 | "dev": true, 486 | "license": "MIT" 487 | }, 488 | "node_modules/@types/node": { 489 | "version": "20.19.1", 490 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", 491 | "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", 492 | "dev": true, 493 | "license": "MIT", 494 | "dependencies": { 495 | "undici-types": "~6.21.0" 496 | } 497 | }, 498 | "node_modules/dotenv": { 499 | "version": "16.5.0", 500 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 501 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 502 | "license": "BSD-2-Clause", 503 | "engines": { 504 | "node": ">=12" 505 | }, 506 | "funding": { 507 | "url": "https://dotenvx.com" 508 | } 509 | }, 510 | "node_modules/esbuild": { 511 | "version": "0.25.5", 512 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", 513 | "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", 514 | "dev": true, 515 | "hasInstallScript": true, 516 | "license": "MIT", 517 | "bin": { 518 | "esbuild": "bin/esbuild" 519 | }, 520 | "engines": { 521 | "node": ">=18" 522 | }, 523 | "optionalDependencies": { 524 | "@esbuild/aix-ppc64": "0.25.5", 525 | "@esbuild/android-arm": "0.25.5", 526 | "@esbuild/android-arm64": "0.25.5", 527 | "@esbuild/android-x64": "0.25.5", 528 | "@esbuild/darwin-arm64": "0.25.5", 529 | "@esbuild/darwin-x64": "0.25.5", 530 | "@esbuild/freebsd-arm64": "0.25.5", 531 | "@esbuild/freebsd-x64": "0.25.5", 532 | "@esbuild/linux-arm": "0.25.5", 533 | "@esbuild/linux-arm64": "0.25.5", 534 | "@esbuild/linux-ia32": "0.25.5", 535 | "@esbuild/linux-loong64": "0.25.5", 536 | "@esbuild/linux-mips64el": "0.25.5", 537 | "@esbuild/linux-ppc64": "0.25.5", 538 | "@esbuild/linux-riscv64": "0.25.5", 539 | "@esbuild/linux-s390x": "0.25.5", 540 | "@esbuild/linux-x64": "0.25.5", 541 | "@esbuild/netbsd-arm64": "0.25.5", 542 | "@esbuild/netbsd-x64": "0.25.5", 543 | "@esbuild/openbsd-arm64": "0.25.5", 544 | "@esbuild/openbsd-x64": "0.25.5", 545 | "@esbuild/sunos-x64": "0.25.5", 546 | "@esbuild/win32-arm64": "0.25.5", 547 | "@esbuild/win32-ia32": "0.25.5", 548 | "@esbuild/win32-x64": "0.25.5" 549 | } 550 | }, 551 | "node_modules/fsevents": { 552 | "version": "2.3.3", 553 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 554 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 555 | "dev": true, 556 | "hasInstallScript": true, 557 | "license": "MIT", 558 | "optional": true, 559 | "os": [ 560 | "darwin" 561 | ], 562 | "engines": { 563 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 564 | } 565 | }, 566 | "node_modules/get-tsconfig": { 567 | "version": "4.10.1", 568 | "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", 569 | "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", 570 | "dev": true, 571 | "license": "MIT", 572 | "dependencies": { 573 | "resolve-pkg-maps": "^1.0.0" 574 | }, 575 | "funding": { 576 | "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 577 | } 578 | }, 579 | "node_modules/lodash": { 580 | "version": "4.17.21", 581 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 582 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", 583 | "license": "MIT" 584 | }, 585 | "node_modules/moment": { 586 | "version": "2.30.1", 587 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", 588 | "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", 589 | "license": "MIT", 590 | "engines": { 591 | "node": "*" 592 | } 593 | }, 594 | "node_modules/resolve-pkg-maps": { 595 | "version": "1.0.0", 596 | "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 597 | "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 598 | "dev": true, 599 | "license": "MIT", 600 | "funding": { 601 | "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 602 | } 603 | }, 604 | "node_modules/tsx": { 605 | "version": "4.20.3", 606 | "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", 607 | "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", 608 | "dev": true, 609 | "license": "MIT", 610 | "dependencies": { 611 | "esbuild": "~0.25.0", 612 | "get-tsconfig": "^4.7.5" 613 | }, 614 | "bin": { 615 | "tsx": "dist/cli.mjs" 616 | }, 617 | "engines": { 618 | "node": ">=18.0.0" 619 | }, 620 | "optionalDependencies": { 621 | "fsevents": "~2.3.3" 622 | } 623 | }, 624 | "node_modules/typescript": { 625 | "version": "5.8.3", 626 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 627 | "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 628 | "dev": true, 629 | "license": "Apache-2.0", 630 | "bin": { 631 | "tsc": "bin/tsc", 632 | "tsserver": "bin/tsserver" 633 | }, 634 | "engines": { 635 | "node": ">=14.17" 636 | } 637 | }, 638 | "node_modules/undici-types": { 639 | "version": "6.21.0", 640 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 641 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 642 | "dev": true, 643 | "license": "MIT" 644 | } 645 | } 646 | } 647 | -------------------------------------------------------------------------------- /examples/book-mode-conference/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-book-mode-conference-example", 3 | "version": "1.0.0", 4 | "description": "Example for creating a book mode conference note with HackMD API", 5 | "main": "index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "tsx index.ts", 9 | "dev": "tsx watch index.ts" 10 | }, 11 | "dependencies": { 12 | "@hackmd/api": "file:../../nodejs", 13 | "dotenv": "^16.4.5", 14 | "lodash": "^4.17.21", 15 | "moment": "^2.29.4" 16 | }, 17 | "devDependencies": { 18 | "@types/lodash": "^4.14.202", 19 | "@types/node": "^20.10.6", 20 | "tsx": "^4.7.0", 21 | "typescript": "^5.3.3" 22 | } 23 | } -------------------------------------------------------------------------------- /examples/book-mode-conference/sessions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "session-001", 4 | "title": "Welcome to DevOpsDays", 5 | "speaker": [ 6 | { 7 | "speaker": { 8 | "public_name": "John Doe" 9 | } 10 | } 11 | ], 12 | "session_type": "keynote", 13 | "started_at": "2025-03-15T09:00:00Z", 14 | "finished_at": "2025-03-15T09:30:00Z", 15 | "tags": ["welcome", "keynote"], 16 | "classroom": { 17 | "tw_name": "主舞台", 18 | "en_name": "Main Stage" 19 | }, 20 | "language": "en", 21 | "difficulty": "General" 22 | }, 23 | { 24 | "id": "session-002", 25 | "title": "Introduction to CI/CD", 26 | "speaker": [ 27 | { 28 | "speaker": { 29 | "public_name": "Jane Smith" 30 | } 31 | } 32 | ], 33 | "session_type": "talk", 34 | "started_at": "2025-03-15T10:00:00Z", 35 | "finished_at": "2025-03-15T10:45:00Z", 36 | "tags": ["ci", "cd", "automation"], 37 | "classroom": { 38 | "tw_name": "A會議室", 39 | "en_name": "Room A" 40 | }, 41 | "language": "en", 42 | "difficulty": "Beginner" 43 | }, 44 | { 45 | "id": "session-003", 46 | "title": "Advanced Kubernetes Operations", 47 | "speaker": [ 48 | { 49 | "speaker": { 50 | "public_name": "Alex Chen" 51 | } 52 | }, 53 | { 54 | "speaker": { 55 | "public_name": "Sarah Wilson" 56 | } 57 | } 58 | ], 59 | "session_type": "workshop", 60 | "started_at": "2025-03-15T11:00:00Z", 61 | "finished_at": "2025-03-15T12:00:00Z", 62 | "tags": ["kubernetes", "containers", "orchestration"], 63 | "classroom": { 64 | "tw_name": "B會議室", 65 | "en_name": "Room B" 66 | }, 67 | "language": "en", 68 | "difficulty": "Advanced" 69 | }, 70 | { 71 | "id": "session-004", 72 | "title": "DevOps Culture and Practices", 73 | "speaker": [ 74 | { 75 | "speaker": { 76 | "public_name": "Mike Johnson" 77 | } 78 | } 79 | ], 80 | "session_type": "talk", 81 | "started_at": "2025-03-15T14:00:00Z", 82 | "finished_at": "2025-03-15T14:45:00Z", 83 | "tags": ["culture", "practices", "team"], 84 | "classroom": { 85 | "tw_name": "主舞台", 86 | "en_name": "Main Stage" 87 | }, 88 | "language": "en", 89 | "difficulty": "General" 90 | }, 91 | { 92 | "id": "session-005", 93 | "title": "監控與可觀測性", 94 | "speaker": [ 95 | { 96 | "speaker": { 97 | "public_name": "林小明" 98 | } 99 | } 100 | ], 101 | "session_type": "talk", 102 | "started_at": "2025-03-16T09:30:00Z", 103 | "finished_at": "2025-03-16T10:15:00Z", 104 | "tags": ["monitoring", "observability"], 105 | "classroom": { 106 | "tw_name": "A會議室", 107 | "en_name": "Room A" 108 | }, 109 | "language": "zh-TW", 110 | "difficulty": "Intermediate" 111 | }, 112 | { 113 | "id": "session-006", 114 | "title": "Security in DevOps Pipeline", 115 | "speaker": [ 116 | { 117 | "speaker": { 118 | "public_name": "Emma Davis" 119 | } 120 | } 121 | ], 122 | "session_type": "workshop", 123 | "started_at": "2025-03-16T10:30:00Z", 124 | "finished_at": "2025-03-16T12:00:00Z", 125 | "tags": ["security", "devsecops", "pipeline"], 126 | "classroom": { 127 | "tw_name": "C會議室", 128 | "en_name": "Room C" 129 | }, 130 | "language": "en", 131 | "difficulty": "Intermediate" 132 | } 133 | ] -------------------------------------------------------------------------------- /examples/book-mode-conference/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "declaration": true, 12 | "outDir": "./dist", 13 | "rootDir": "./", 14 | "resolveJsonModule": true 15 | }, 16 | "include": ["*.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } -------------------------------------------------------------------------------- /examples/nodejs/.env.example: -------------------------------------------------------------------------------- 1 | HACKMD_ACCESS_TOKEN=YOUR_ACCESS_TOKEN -------------------------------------------------------------------------------- /examples/nodejs/README.md: -------------------------------------------------------------------------------- 1 | # HackMD API Client Example 2 | 3 | This is an example project demonstrating the usage of the HackMD API client. 4 | 5 | ## Setup 6 | 7 | 1. First, build the HackMD API package: 8 | ```bash 9 | cd ../../nodejs 10 | npm install 11 | npm run build 12 | cd ../examples/nodejs 13 | ``` 14 | 15 | 2. Install the example dependencies: 16 | ```bash 17 | npm install 18 | ``` 19 | 20 | 3. Set up your HackMD access token using one of these methods: 21 | 22 | a. Set it as an environment variable: 23 | ```bash 24 | # For Unix/Linux/macOS 25 | export HACKMD_ACCESS_TOKEN=your_access_token_here 26 | 27 | # For Windows PowerShell 28 | $env:HACKMD_ACCESS_TOKEN="your_access_token_here" 29 | ``` 30 | 31 | b. Or create a `.env` file in the project root (not tracked by git): 32 | ``` 33 | HACKMD_ACCESS_TOKEN=your_access_token_here 34 | ``` 35 | 36 | You can get your access token from [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). 37 | 38 | ## Running the Example 39 | 40 | To run the example: 41 | 42 | ```bash 43 | npm start 44 | ``` 45 | 46 | ## What's Demonstrated 47 | 48 | The example demonstrates several features of the HackMD API client: 49 | 50 | 1. Getting user information 51 | 2. Creating a new note 52 | 3. Using ETag support for caching 53 | 4. Updating note content 54 | 5. Getting raw response data 55 | 6. Deleting notes 56 | 57 | ## Features Shown 58 | 59 | - Retry configuration with exponential backoff 60 | - ETag support for caching 61 | - Response data unwrapping 62 | - Error handling 63 | - Environment variable configuration -------------------------------------------------------------------------------- /examples/nodejs/index.js: -------------------------------------------------------------------------------- 1 | import HackMDAPI from '@hackmd/api'; 2 | import dotenv from 'dotenv'; 3 | 4 | // Load environment variables 5 | dotenv.config(); 6 | 7 | // Check for required environment variable 8 | if (!process.env.HACKMD_ACCESS_TOKEN) { 9 | console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.'); 10 | console.error('Please set your HackMD access token using one of these methods:'); 11 | console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here'); 12 | console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here'); 13 | process.exit(1); 14 | } 15 | 16 | // Create API client with retry configuration 17 | const client = new HackMDAPI(process.env.HACKMD_ACCESS_TOKEN, 'https://api.hackmd.io/v1', { 18 | retryConfig: { 19 | maxRetries: 3, 20 | baseDelay: 100 21 | } 22 | }); 23 | 24 | async function main() { 25 | try { 26 | // Example 1: Get user information 27 | console.log('Getting user information...'); 28 | const me = await client.getMe(); 29 | console.log('User email:', me.email); 30 | console.log('User name:', me.name); 31 | 32 | // Example 2: Create a new note 33 | console.log('\nCreating a new note...'); 34 | const newNote = await client.createNote({ 35 | title: 'Test Note', 36 | content: '# Hello from HackMD API\n\nThis is a test note created using the API client.', 37 | readPermission: 'guest', 38 | writePermission: 'owner' 39 | }); 40 | console.log('Created note ID:', newNote.id); 41 | console.log('Note URL:', newNote.publishLink); 42 | 43 | // Example 3: Get note with ETag support 44 | console.log('\nGetting note with ETag support...'); 45 | const note = await client.getNote(newNote.id); 46 | console.log('Note content:', note.content); 47 | 48 | // Second request with ETag 49 | const updatedNote = await client.getNote(newNote.id, { etag: note.etag }); 50 | console.log('Note status:', updatedNote.status); 51 | 52 | // Example 4: Update note content 53 | console.log('\nUpdating note content...'); 54 | const updatedContent = await client.updateNoteContent(newNote.id, '# Updated Content\n\nThis note has been updated!'); 55 | console.log('Updated note content:', updatedContent.content); 56 | 57 | // Example 5: Get raw response (unwrapData: false) 58 | console.log('\nGetting raw response...'); 59 | const rawResponse = await client.getNote(newNote.id, { unwrapData: false }); 60 | console.log('Response headers:', rawResponse.headers); 61 | console.log('Response status:', rawResponse.status); 62 | 63 | // Example 6: Delete the test note 64 | console.log('\nCleaning up - deleting test note...'); 65 | await client.deleteNote(newNote.id); 66 | console.log('Note deleted successfully'); 67 | 68 | } catch (error) { 69 | console.error('Error:', error.message); 70 | if (error.response) { 71 | console.error('Response status:', error.response.status); 72 | console.error('Response data:', error.response.data); 73 | } 74 | } 75 | } 76 | 77 | main(); -------------------------------------------------------------------------------- /examples/nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-example", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "hackmd-api-example", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "@hackmd/api": "file:../../nodejs", 12 | "dotenv": "^16.4.5" 13 | } 14 | }, 15 | "../../nodejs": { 16 | "name": "@hackmd/api", 17 | "version": "2.4.0", 18 | "license": "MIT", 19 | "dependencies": { 20 | "axios": "^1.8.4", 21 | "tslib": "^1.14.1" 22 | }, 23 | "devDependencies": { 24 | "@faker-js/faker": "^7.6.0", 25 | "@rollup/plugin-commonjs": "^28.0.3", 26 | "@rollup/plugin-node-resolve": "^16.0.1", 27 | "@rollup/plugin-typescript": "^12.1.2", 28 | "@types/eslint": "^8.21.0", 29 | "@types/jest": "^29.4.0", 30 | "@types/node": "^13.11.1", 31 | "@typescript-eslint/eslint-plugin": "^6.21.0", 32 | "@typescript-eslint/parser": "^6.21.0", 33 | "dotenv": "^16.0.3", 34 | "eslint": "^8.57.1", 35 | "jest": "^29.4.2", 36 | "msw": "^2.7.3", 37 | "rimraf": "^4.1.2", 38 | "rollup": "^4.41.1", 39 | "ts-jest": "^29.0.5", 40 | "ts-node": "^8.8.2", 41 | "typescript": "^4.9.5" 42 | } 43 | }, 44 | "node_modules/@hackmd/api": { 45 | "resolved": "../../nodejs", 46 | "link": true 47 | }, 48 | "node_modules/dotenv": { 49 | "version": "16.5.0", 50 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 51 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 52 | "license": "BSD-2-Clause", 53 | "engines": { 54 | "node": ">=12" 55 | }, 56 | "funding": { 57 | "url": "https://dotenvx.com" 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /examples/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-example", 3 | "version": "1.0.0", 4 | "description": "Example usage of HackMD API client", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node index.js" 9 | }, 10 | "dependencies": { 11 | "@hackmd/api": "file:../../nodejs", 12 | "dotenv": "^16.4.5" 13 | } 14 | } -------------------------------------------------------------------------------- /nodejs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | /** @type {import('eslint').Linter.Config} */ 4 | const config = { 5 | "parser": "@typescript-eslint/parser", 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "rules": { 11 | "no-trailing-spaces": ["warn", { "skipBlankLines": false }], 12 | "semi": ["warn", "never"], 13 | "@typescript-eslint/no-non-null-assertion": "off", 14 | "keyword-spacing": ["warn", {"before": true, "after": true}], 15 | "space-infix-ops": "warn", 16 | "space-before-function-paren": "warn", 17 | "eol-last": ["error", "always"] 18 | }, 19 | "parserOptions": { 20 | "project": [ 21 | path.resolve(__dirname, "tsconfig.json"), 22 | ] 23 | }, 24 | "ignorePatterns": [ 25 | ".eslintrc.cjs" 26 | ], 27 | } 28 | 29 | module.exports = config -------------------------------------------------------------------------------- /nodejs/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .pnp.* 116 | -------------------------------------------------------------------------------- /nodejs/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /nodejs/README.md: -------------------------------------------------------------------------------- 1 | # HackMD Node.js API Client 2 | 3 | ![npm](https://img.shields.io/npm/v/@hackmd/api) 4 | 5 | ## About 6 | 7 | This is a Node.js client for the [HackMD API](https://hackmd.io/). 8 | 9 | You can sign up for an account at [hackmd.io](https://hackmd.io/), and then create access tokens for your projects by following the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). 10 | 11 | For bugs and feature requests, please open an issue or pull request on [GitHub](https://github.com/hackmdio/api-client). 12 | 13 | ## **v2.0.0 Update Note** 14 | 15 | `v2.0.0` is a completely rewrite and is incompatible with `v1.x.x`. But the best of all, it does not require Node.JS runtime anymore, which means you can use it in a browser. We recommend you to upgrade to `v2.0.0` if you are using the old one. 16 | 17 | ## Installation 18 | 19 | ```bash 20 | npm install @hackmd/api --save 21 | ``` 22 | 23 | ## Example 24 | 25 | ### ES Modules (ESM) 26 | 27 | ```javascript 28 | // Default import 29 | import HackMDAPI from '@hackmd/api' 30 | 31 | // Or named import 32 | import { API } from '@hackmd/api' 33 | 34 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN' /* required */, 'https://api.hackmd.io/v1' /* optional */) 35 | 36 | client.getMe().then(me => { 37 | console.log(me.email) 38 | }) 39 | ``` 40 | 41 | ### CommonJS 42 | 43 | ```javascript 44 | // Default import 45 | const HackMDAPI = require('@hackmd/api').default 46 | 47 | // Or named import 48 | const { API } = require('@hackmd/api') 49 | 50 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1') 51 | 52 | client.getMe().then(me => { 53 | console.log(me.email) 54 | }) 55 | ``` 56 | 57 | ### Legacy Import Support 58 | 59 | For backward compatibility, the package also supports legacy import paths: 60 | 61 | ```javascript 62 | // ESM 63 | import HackMDAPI from '@hackmd/api/dist' 64 | import { API } from '@hackmd/api/dist' 65 | 66 | // CommonJS 67 | const HackMDAPI = require('@hackmd/api/dist').default 68 | const { API } = require('@hackmd/api/dist') 69 | 70 | // Direct file imports 71 | import { API } from '@hackmd/api/dist/index.js' 72 | ``` 73 | 74 | ## Advanced Features 75 | 76 | ### Retry Configuration 77 | 78 | The client supports automatic retry for failed requests with exponential backoff. You can configure retry behavior when creating the client: 79 | 80 | ```javascript 81 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1', { 82 | retryConfig: { 83 | maxRetries: 3, // Maximum number of retry attempts 84 | baseDelay: 100 // Base delay in milliseconds for exponential backoff 85 | } 86 | }) 87 | ``` 88 | 89 | The client will automatically retry requests that fail with: 90 | - 5xx server errors 91 | - 429 Too Many Requests errors 92 | - Network errors 93 | 94 | ### Response Data Handling 95 | 96 | By default, the client automatically unwraps the response data from the Axios response object. You can control this behavior using the `unwrapData` option: 97 | 98 | ```javascript 99 | // Get raw Axios response (includes headers, status, etc.) 100 | const response = await client.getMe({ unwrapData: false }) 101 | 102 | // Get only the data (default behavior) 103 | const data = await client.getMe({ unwrapData: true }) 104 | ``` 105 | 106 | ### ETag Support 107 | 108 | The client supports ETag-based caching for note retrieval. You can pass an ETag to check if the content has changed: 109 | 110 | ```javascript 111 | // First request 112 | const note = await client.getNote('note-id') 113 | const etag = note.etag 114 | 115 | // Subsequent request with ETag 116 | const updatedNote = await client.getNote('note-id', { etag }) 117 | // If the note hasn't changed, the response will have status 304 118 | ``` 119 | 120 | ## API 121 | 122 | 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. 123 | 124 | ## License 125 | 126 | MIT 127 | -------------------------------------------------------------------------------- /nodejs/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest" 2 | 3 | const customJestConfig: JestConfigWithTsJest = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | transformIgnorePatterns: ["/node_modules/"], 7 | extensionsToTreatAsEsm: [".ts"], 8 | setupFiles: ["dotenv/config"], 9 | } 10 | 11 | export default customJestConfig 12 | -------------------------------------------------------------------------------- /nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackmd/api", 3 | "version": "2.5.0", 4 | "description": "HackMD Node.js API Client", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs", 13 | "types": "./dist/index.d.ts" 14 | }, 15 | "./dist": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.cjs", 18 | "types": "./dist/index.d.ts" 19 | }, 20 | "./dist/*": "./dist/*" 21 | }, 22 | "scripts": { 23 | "clean": "rimraf dist", 24 | "build": "npm run clean && rollup -c", 25 | "watch": "npm run clean && rollup -c -w", 26 | "prepublishOnly": "npm run build", 27 | "lint": "eslint src --fix --ext .ts", 28 | "test": "jest" 29 | }, 30 | "keywords": [ 31 | "HackMD", 32 | "API Client" 33 | ], 34 | "files": [ 35 | "dist/*", 36 | "README.md", 37 | "LICENSE" 38 | ], 39 | "author": { 40 | "name": "HackMD", 41 | "email": "support@hackmd.io", 42 | "url": "https://hackmd.io" 43 | }, 44 | "license": "MIT", 45 | "devDependencies": { 46 | "@faker-js/faker": "^7.6.0", 47 | "@rollup/plugin-commonjs": "^28.0.3", 48 | "@rollup/plugin-node-resolve": "^16.0.1", 49 | "@rollup/plugin-typescript": "^12.1.2", 50 | "@types/eslint": "^8.21.0", 51 | "@types/jest": "^29.4.0", 52 | "@types/node": "^13.11.1", 53 | "@typescript-eslint/eslint-plugin": "^6.21.0", 54 | "@typescript-eslint/parser": "^6.21.0", 55 | "dotenv": "^16.0.3", 56 | "eslint": "^8.57.1", 57 | "jest": "^29.4.2", 58 | "msw": "^2.7.3", 59 | "rimraf": "^4.1.2", 60 | "rollup": "^4.41.1", 61 | "ts-jest": "^29.0.5", 62 | "ts-node": "^8.8.2", 63 | "typescript": "^4.9.5" 64 | }, 65 | "dependencies": { 66 | "axios": "^1.8.4", 67 | "tslib": "^1.14.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /nodejs/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import typescript from '@rollup/plugin-typescript' 4 | 5 | export default [ 6 | // ESM build 7 | { 8 | input: 'src/index.ts', 9 | output: { 10 | file: 'dist/index.js', 11 | format: 'esm', 12 | sourcemap: true, 13 | }, 14 | plugins: [ 15 | resolve({ 16 | extensions: ['.ts', '.js'] 17 | }), 18 | commonjs(), 19 | typescript({ 20 | tsconfig: './tsconfig.build.json', 21 | declaration: true, 22 | declarationDir: './dist', 23 | rootDir: './src' 24 | }) 25 | ], 26 | external: ['axios'] 27 | }, 28 | // CJS build 29 | { 30 | input: 'src/index.ts', 31 | output: { 32 | file: 'dist/index.cjs', 33 | format: 'cjs', 34 | sourcemap: true, 35 | exports: 'named' 36 | }, 37 | plugins: [ 38 | resolve({ 39 | extensions: ['.ts', '.js'] 40 | }), 41 | commonjs(), 42 | typescript({ 43 | tsconfig: './tsconfig.build.json', 44 | declaration: false // Only generate declarations once 45 | }) 46 | ], 47 | external: ['axios'] 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /nodejs/src/error.ts: -------------------------------------------------------------------------------- 1 | export class HackMDError extends Error { 2 | constructor (message: string) { 3 | super(message) 4 | Object.setPrototypeOf(this, new.target.prototype) 5 | } 6 | } 7 | 8 | export class HttpResponseError extends HackMDError { 9 | public constructor ( 10 | message: string, 11 | readonly code: number, 12 | readonly statusText: string, 13 | ) { 14 | super(message) 15 | } 16 | } 17 | 18 | export class MissingRequiredArgument extends HackMDError {} 19 | export class InternalServerError extends HttpResponseError {} 20 | export class TooManyRequestsError extends HttpResponseError { 21 | public constructor ( 22 | message: string, 23 | readonly code: number, 24 | readonly statusText: string, 25 | readonly userLimit: number, 26 | readonly userRemaining: number, 27 | readonly resetAfter?: number, 28 | ) { 29 | super(message, code, statusText) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nodejs/src/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios' 2 | import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote } from './type' 3 | import * as HackMDErrors from './error' 4 | 5 | export type RequestOptions = { 6 | unwrapData?: boolean; 7 | etag?: string | undefined; 8 | } 9 | 10 | const defaultOption: RequestOptions = { 11 | unwrapData: true, 12 | } 13 | 14 | type OptionReturnType = Opt extends { unwrapData: false } ? AxiosResponse : Opt extends { unwrapData: true } ? T : T 15 | 16 | export type APIClientOptions = { 17 | wrapResponseErrors: boolean; 18 | timeout?: number; 19 | retryConfig?: { 20 | maxRetries: number; 21 | baseDelay: number; 22 | }; 23 | } 24 | 25 | export class API { 26 | private axios: AxiosInstance 27 | 28 | constructor ( 29 | readonly accessToken: string, 30 | public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1", 31 | public options: APIClientOptions = { 32 | wrapResponseErrors: true, 33 | timeout: 30000, 34 | retryConfig: { 35 | maxRetries: 3, 36 | baseDelay: 100, 37 | }, 38 | } 39 | ) { 40 | if (!accessToken) { 41 | throw new HackMDErrors.MissingRequiredArgument('Missing access token when creating HackMD client') 42 | } 43 | 44 | this.axios = axios.create({ 45 | baseURL: hackmdAPIEndpointURL, 46 | headers:{ 47 | "Content-Type": "application/json", 48 | }, 49 | timeout: options.timeout 50 | }) 51 | 52 | this.axios.interceptors.request.use( 53 | (config: InternalAxiosRequestConfig) => { 54 | config.headers.set('Authorization', `Bearer ${accessToken}`) 55 | return config 56 | }, 57 | (err: AxiosError) => { 58 | return Promise.reject(err) 59 | } 60 | ) 61 | 62 | if (options.wrapResponseErrors) { 63 | this.axios.interceptors.response.use( 64 | (response: AxiosResponse) => { 65 | return response 66 | }, 67 | async (err: AxiosError) => { 68 | if (!err.response) { 69 | return Promise.reject(err) 70 | } 71 | 72 | if (err.response.status >= 500) { 73 | throw new HackMDErrors.InternalServerError( 74 | `HackMD internal error (${err.response.status} ${err.response.statusText})`, 75 | err.response.status, 76 | err.response.statusText, 77 | ) 78 | } else if (err.response.status === 429) { 79 | throw new HackMDErrors.TooManyRequestsError( 80 | `Too many requests (${err.response.status} ${err.response.statusText})`, 81 | err.response.status, 82 | err.response.statusText, 83 | parseInt(err.response.headers['x-ratelimit-userlimit'], 10), 84 | parseInt(err.response.headers['x-ratelimit-userremaining'], 10), 85 | parseInt(err.response.headers['x-ratelimit-userreset'], 10), 86 | ) 87 | } else { 88 | throw new HackMDErrors.HttpResponseError( 89 | `Received an error response (${err.response.status} ${err.response.statusText}) from HackMD`, 90 | err.response.status, 91 | err.response.statusText, 92 | ) 93 | } 94 | } 95 | ) 96 | } 97 | if (options.retryConfig) { 98 | this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) 99 | } 100 | } 101 | 102 | private exponentialBackoff (retries: number, baseDelay: number): number { 103 | return Math.pow(2, retries) * baseDelay 104 | } 105 | 106 | private isRetryableError (error: AxiosError): boolean { 107 | return ( 108 | !error.response || 109 | (error.response.status >= 500 && error.response.status < 600) || 110 | error.response.status === 429 111 | ) 112 | } 113 | 114 | private createRetryInterceptor (axiosInstance: AxiosInstance, maxRetries: number, baseDelay: number): void { 115 | let retryCount = 0 116 | 117 | axiosInstance.interceptors.response.use( 118 | response => response, 119 | async error => { 120 | if (retryCount < maxRetries && this.isRetryableError(error)) { 121 | const remainingCredits = parseInt(error.response?.headers['x-ratelimit-userremaining'], 10) 122 | 123 | if (isNaN(remainingCredits) || remainingCredits > 0) { 124 | retryCount++ 125 | const delay = this.exponentialBackoff(retryCount, baseDelay) 126 | console.warn(`Retrying request... attempt #${retryCount} after delay of ${delay}ms`) 127 | await new Promise(resolve => setTimeout(resolve, delay)) 128 | return axiosInstance(error.config) 129 | } 130 | } 131 | 132 | retryCount = 0 // Reset retry count after a successful request or when not retrying 133 | return Promise.reject(error) 134 | } 135 | ) 136 | } 137 | async getMe (options = defaultOption as Opt): Promise> { 138 | return this.unwrapData(this.axios.get("me"), options.unwrapData) as unknown as OptionReturnType 139 | } 140 | 141 | async getHistory (options = defaultOption as Opt): Promise> { 142 | return this.unwrapData(this.axios.get("history"), options.unwrapData) as unknown as OptionReturnType 143 | } 144 | 145 | async getNoteList (options = defaultOption as Opt): Promise> { 146 | return this.unwrapData(this.axios.get("notes"), options.unwrapData) as unknown as OptionReturnType 147 | } 148 | 149 | async getNote (noteId: string, options = defaultOption as Opt): Promise> { 150 | // Prepare request config with etag if provided in options 151 | const config = options.etag ? { 152 | headers: { 'If-None-Match': options.etag }, 153 | // Consider 304 responses as successful 154 | validateStatus: (status: number) => (status >= 200 && status < 300) || status === 304 155 | } : undefined 156 | const request = this.axios.get(`notes/${noteId}`, config) 157 | return this.unwrapData(request, options.unwrapData, true) as unknown as OptionReturnType 158 | } 159 | 160 | async createNote (payload: CreateNoteOptions, options = defaultOption as Opt): Promise> { 161 | return this.unwrapData(this.axios.post("notes", payload), options.unwrapData, true) as unknown as OptionReturnType 162 | } 163 | 164 | async updateNoteContent (noteId: string, content?: string, options = defaultOption as Opt): Promise> { 165 | return this.unwrapData(this.axios.patch(`notes/${noteId}`, { content }), options.unwrapData, true) as unknown as OptionReturnType 166 | } 167 | 168 | async updateNote (noteId: string, payload: Partial>, options = defaultOption as Opt): Promise> { 169 | return this.unwrapData(this.axios.patch(`notes/${noteId}`, payload), options.unwrapData, true) as unknown as OptionReturnType 170 | } 171 | 172 | async deleteNote (noteId: string, options = defaultOption as Opt): Promise> { 173 | return this.unwrapData(this.axios.delete(`notes/${noteId}`), options.unwrapData) as unknown as OptionReturnType 174 | } 175 | 176 | async getTeams (options = defaultOption as Opt): Promise> { 177 | return this.unwrapData(this.axios.get("teams"), options.unwrapData) as unknown as OptionReturnType 178 | } 179 | 180 | async getTeamNotes (teamPath: string, options = defaultOption as Opt): Promise> { 181 | return this.unwrapData(this.axios.get(`teams/${teamPath}/notes`), options.unwrapData) as unknown as OptionReturnType 182 | } 183 | 184 | async createTeamNote (teamPath: string, payload: CreateNoteOptions, options = defaultOption as Opt): Promise> { 185 | return this.unwrapData(this.axios.post(`teams/${teamPath}/notes`, payload), options.unwrapData) as unknown as OptionReturnType 186 | } 187 | 188 | async updateTeamNoteContent (teamPath: string, noteId: string, content?: string): Promise { 189 | return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, { content }) 190 | } 191 | 192 | async updateTeamNote (teamPath: string, noteId: string, options: Partial>): Promise { 193 | return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, options) 194 | } 195 | 196 | async deleteTeamNote (teamPath: string, noteId: string): Promise { 197 | return this.axios.delete(`teams/${teamPath}/notes/${noteId}`) 198 | } 199 | 200 | private unwrapData (reqP: Promise>, unwrap = true, includeEtag = false) { 201 | if (!unwrap) { 202 | // For raw responses, etag is available via response.headers 203 | return reqP 204 | } 205 | return reqP.then(response => { 206 | const data = response.data 207 | if (!includeEtag) return data 208 | const etag = response.headers.etag || response.headers['ETag'] 209 | return { ...data, status: response.status, etag } 210 | }) 211 | } 212 | } 213 | 214 | export default API 215 | -------------------------------------------------------------------------------- /nodejs/src/type.ts: -------------------------------------------------------------------------------- 1 | export enum TeamVisibilityType { 2 | PUBLIC = 'public', 3 | PRIVATE = 'private', 4 | } 5 | 6 | export enum NotePublishType { 7 | EDIT = 'edit', 8 | VIEW = 'view', 9 | SLIDE = 'slide', 10 | BOOK = 'book' 11 | } 12 | 13 | export enum CommentPermissionType { 14 | DISABLED = 'disabled', 15 | FORBIDDEN = 'forbidden', 16 | OWNERS = 'owners', 17 | SIGNED_IN_USERS = 'signed_in_users', 18 | EVERYONE = 'everyone' 19 | } 20 | 21 | export type CreateNoteOptions = { 22 | title?: string 23 | content?: string 24 | readPermission?: NotePermissionRole, 25 | writePermission?: NotePermissionRole, 26 | commentPermission?: CommentPermissionType, 27 | permalink?: string 28 | } 29 | 30 | export type Team = { 31 | id: string 32 | ownerId: string 33 | name: string 34 | logo: string 35 | path: string 36 | description: string 37 | hardBreaks: boolean 38 | visibility: TeamVisibilityType 39 | createdAt: Date 40 | } 41 | 42 | export type User = { 43 | id: string 44 | email: string | null 45 | name: string 46 | userPath: string 47 | photo: string 48 | teams: Team[] 49 | } 50 | 51 | export type SimpleUserProfile = { 52 | name: string, 53 | userPath: string 54 | photo: string 55 | biography: string | null 56 | createdAt: Date 57 | } 58 | 59 | export enum NotePermissionRole { 60 | OWNER = 'owner', 61 | SIGNED_IN = 'signed_in', 62 | GUEST = 'guest' 63 | } 64 | 65 | export type Note = { 66 | id: string 67 | title: string 68 | tags: string[] 69 | lastChangedAt: string 70 | createdAt: string 71 | lastChangeUser: SimpleUserProfile | null 72 | publishType: NotePublishType 73 | publishedAt: string | null 74 | userPath: string | null 75 | teamPath: string | null 76 | permalink: string | null 77 | shortId: string 78 | publishLink: string 79 | 80 | readPermission: NotePermissionRole 81 | writePermission: NotePermissionRole 82 | } 83 | 84 | export type SingleNote = Note & { 85 | content: string 86 | } 87 | 88 | // User 89 | export type GetMe = User 90 | 91 | // User notes 92 | export type GetUserNotes = Note[] 93 | export type GetUserNote = SingleNote 94 | export type GetUserHistory = Note[] 95 | export type CreateUserNote = SingleNote 96 | export type UpdateUserNote = void 97 | export type DeleteUserNote = void 98 | 99 | // Teams 100 | export type GetUserTeams = Team[] 101 | 102 | // Team notes 103 | export type GetTeamNotes = Note[] 104 | export type CreateTeamNote = SingleNote 105 | export type UpdateTeamNote = void 106 | export type DeleteTeamNote = void 107 | 108 | 109 | -------------------------------------------------------------------------------- /nodejs/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import { server } from './mock' 2 | import { API } from '../src' 3 | import { http, HttpResponse } from 'msw' 4 | import { TooManyRequestsError } from '../src/error' 5 | 6 | let client: API 7 | 8 | beforeAll(() => { 9 | client = new API(process.env.HACKMD_ACCESS_TOKEN!) 10 | 11 | return server.listen() 12 | }) 13 | 14 | afterEach(() => { 15 | server.resetHandlers() 16 | }) 17 | 18 | afterAll(() => { 19 | server.close() 20 | // Add explicit cleanup to ensure Jest exits properly 21 | return new Promise(resolve => setTimeout(resolve, 100)) 22 | }) 23 | 24 | test('getMe', async () => { 25 | const response = await client.getMe({ unwrapData: false }) 26 | 27 | expect(response).toHaveProperty('status', 200) 28 | expect(response).toHaveProperty('headers') 29 | }) 30 | 31 | test('getMe unwrapped', async () => { 32 | const response = await client.getMe() 33 | 34 | expect(typeof response).toBe('object') 35 | expect(response).toHaveProperty('id') 36 | expect(response).toHaveProperty('name') 37 | expect(response).toHaveProperty('email') 38 | expect(response).toHaveProperty('userPath') 39 | expect(response).toHaveProperty('photo') 40 | }) 41 | 42 | test('should throw axios error object if set wrapResponseErrors to false', async () => { 43 | const customCilent = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { 44 | wrapResponseErrors: false, 45 | }) 46 | 47 | server.use( 48 | http.get('https://api.hackmd.io/v1/me', () => { 49 | return new HttpResponse(null, { 50 | status: 429 51 | }) 52 | }) 53 | ) 54 | 55 | try { 56 | await customCilent.getMe() 57 | } catch (error: any) { 58 | expect(error).toHaveProperty('response') 59 | expect(error.response).toHaveProperty('status', 429) 60 | } 61 | }) 62 | 63 | test('should throw HackMD error object', async () => { 64 | // Create a client with retry disabled to avoid conflicts with error handling test 65 | const clientWithoutRetry = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { 66 | wrapResponseErrors: true, 67 | retryConfig: undefined // Disable retry logic for this test 68 | }) 69 | 70 | server.use( 71 | http.get('https://api.hackmd.io/v1/me', () => { 72 | return HttpResponse.json( 73 | {}, 74 | { 75 | status: 429, 76 | headers: { 77 | 'X-RateLimit-UserLimit': '100', 78 | 'x-RateLimit-UserRemaining': '0', 79 | 'x-RateLimit-UserReset': String( 80 | new Date().getTime() + 1000 * 60 * 60 * 24, 81 | ), 82 | }, 83 | } 84 | ) 85 | }) 86 | ) 87 | 88 | try { 89 | await clientWithoutRetry.getMe() 90 | // If we get here, the test should fail because an error wasn't thrown 91 | expect('no error thrown').toBe('error should have been thrown') 92 | } catch (error: any) { 93 | expect(error).toBeInstanceOf(TooManyRequestsError) 94 | expect(error).toHaveProperty('code', 429) 95 | expect(error).toHaveProperty('statusText', 'Too Many Requests') 96 | expect(error).toHaveProperty('userLimit', 100) 97 | expect(error).toHaveProperty('userRemaining', 0) 98 | expect(error).toHaveProperty('resetAfter') 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /nodejs/tests/etag.spec.ts: -------------------------------------------------------------------------------- 1 | import { server } from './mock' 2 | import { API } from '../src' 3 | import { http, HttpResponse } from 'msw' 4 | 5 | let client: API 6 | 7 | beforeAll(() => { 8 | client = new API(process.env.HACKMD_ACCESS_TOKEN!) 9 | return server.listen() 10 | }) 11 | 12 | afterEach(() => { 13 | server.resetHandlers() 14 | }) 15 | 16 | afterAll(() => { 17 | server.close() 18 | // Add explicit cleanup to ensure Jest exits properly 19 | return new Promise(resolve => setTimeout(resolve, 100)) 20 | }) 21 | 22 | describe('Etag support', () => { 23 | // Helper to reset server between tests 24 | beforeEach(() => { 25 | server.resetHandlers() 26 | }) 27 | 28 | describe('getNote', () => { 29 | test('should include etag property in response when unwrapData is true', async () => { 30 | // Setup mock server to return an etag 31 | const mockEtag = 'W/"123456789"' 32 | 33 | server.use( 34 | http.get('https://api.hackmd.io/v1/notes/test-note-id', () => { 35 | return HttpResponse.json( 36 | { 37 | id: 'test-note-id', 38 | title: 'Test Note' 39 | }, 40 | { 41 | headers: { 42 | 'ETag': mockEtag 43 | } 44 | } 45 | ) 46 | }) 47 | ) 48 | 49 | // Make request with default unwrapData: true 50 | const response = await client.getNote('test-note-id') 51 | 52 | // Verify response has etag property 53 | expect(response).toHaveProperty('etag', mockEtag) 54 | 55 | // Verify data properties still exist 56 | expect(response).toHaveProperty('id', 'test-note-id') 57 | expect(response).toHaveProperty('title', 'Test Note') 58 | }) 59 | 60 | test('should include etag in response headers when unwrapData is false', async () => { 61 | // Setup mock server to return an etag 62 | const mockEtag = 'W/"123456789"' 63 | 64 | server.use( 65 | http.get('https://api.hackmd.io/v1/notes/test-note-id', () => { 66 | return HttpResponse.json( 67 | { 68 | id: 'test-note-id', 69 | title: 'Test Note' 70 | }, 71 | { 72 | headers: { 73 | 'ETag': mockEtag 74 | } 75 | } 76 | ) 77 | }) 78 | ) 79 | 80 | // Make request with unwrapData: false 81 | const response = await client.getNote('test-note-id', { unwrapData: false }) 82 | 83 | // Verify response headers contain etag 84 | expect(response.headers.etag).toBe(mockEtag) 85 | 86 | // Verify data is in response.data 87 | expect(response.data).toHaveProperty('id', 'test-note-id') 88 | expect(response.data).toHaveProperty('title', 'Test Note') 89 | }) 90 | 91 | test('should send etag in If-None-Match header when provided in options', async () => { 92 | // Setup mock server to check for If-None-Match header 93 | let ifNoneMatchValue: string | null = null 94 | const mockEtag = 'W/"123456789"' 95 | 96 | server.use( 97 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 98 | // Store the If-None-Match header value for verification 99 | ifNoneMatchValue = request.headers.get('If-None-Match') 100 | 101 | return HttpResponse.json( 102 | { 103 | id: 'test-note-id', 104 | title: 'Test Note' 105 | }, 106 | { 107 | headers: { 108 | 'ETag': mockEtag 109 | } 110 | } 111 | ) 112 | }) 113 | ) 114 | 115 | // Make request with etag in options 116 | await client.getNote('test-note-id', { etag: mockEtag }) 117 | 118 | // Verify the If-None-Match header was sent with correct value 119 | expect(ifNoneMatchValue).toBe(mockEtag) 120 | }) 121 | 122 | test('should preserve 304 status and etag when unwrapData is false and content not modified', async () => { 123 | // Setup mock server to return 304 when etag matches 124 | const mockEtag = 'W/"123456789"' 125 | 126 | server.use( 127 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 128 | const ifNoneMatch = request.headers.get('If-None-Match') 129 | 130 | // Return 304 when etag matches 131 | if (ifNoneMatch === mockEtag) { 132 | return new HttpResponse(null, { 133 | status: 304, 134 | headers: { 135 | 'ETag': mockEtag 136 | } 137 | }) 138 | } 139 | 140 | return HttpResponse.json( 141 | { 142 | id: 'test-note-id', 143 | title: 'Test Note' 144 | }, 145 | { 146 | headers: { 147 | 'ETag': mockEtag 148 | } 149 | } 150 | ) 151 | }) 152 | ) 153 | 154 | // Request with unwrapData: false to get full response including status 155 | const response = await client.getNote('test-note-id', { etag: mockEtag, unwrapData: false }) 156 | 157 | // Verify we get a 304 status code 158 | expect(response.status).toBe(304) 159 | 160 | // Verify etag is still available in headers 161 | expect(response.headers.etag).toBe(mockEtag) 162 | }) 163 | 164 | test('should return status and etag only when unwrapData is true and content not modified', async () => { 165 | // Setup mock server to return 304 when etag matches 166 | const mockEtag = 'W/"123456789"' 167 | 168 | server.use( 169 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 170 | const ifNoneMatch = request.headers.get('If-None-Match') 171 | 172 | // Return 304 when etag matches 173 | if (ifNoneMatch === mockEtag) { 174 | return new HttpResponse(null, { 175 | status: 304, 176 | headers: { 177 | 'ETag': mockEtag 178 | } 179 | }) 180 | } 181 | 182 | return HttpResponse.json( 183 | { 184 | id: 'test-note-id', 185 | title: 'Test Note' 186 | }, 187 | { 188 | headers: { 189 | 'ETag': mockEtag 190 | } 191 | } 192 | ) 193 | }) 194 | ) 195 | 196 | // Request with default unwrapData: true 197 | const response = await client.getNote('test-note-id', { etag: mockEtag }) 198 | 199 | // With unwrapData: true and a 304 response, we just get the etag 200 | expect(response).toHaveProperty('etag', mockEtag) 201 | expect(response).toHaveProperty('status', 304) 202 | }) 203 | }) 204 | 205 | describe('createNote', () => { 206 | test('should include etag property in response when creating a note', async () => { 207 | // Setup mock server to return an etag 208 | const mockEtag = 'W/"abcdef123"' 209 | 210 | server.use( 211 | http.post('https://api.hackmd.io/v1/notes', () => { 212 | return HttpResponse.json( 213 | { 214 | id: 'new-note-id', 215 | title: 'New Test Note' 216 | }, 217 | { 218 | headers: { 219 | 'ETag': mockEtag 220 | } 221 | } 222 | ) 223 | }) 224 | ) 225 | 226 | // Make request with default unwrapData: true 227 | const response = await client.createNote({ title: 'New Test Note', content: 'Test content' }) 228 | 229 | // Verify response has etag property 230 | expect(response).toHaveProperty('etag', mockEtag) 231 | 232 | // Verify data properties still exist 233 | expect(response).toHaveProperty('id', 'new-note-id') 234 | expect(response).toHaveProperty('title', 'New Test Note') 235 | }) 236 | }) 237 | 238 | describe('updateNote', () => { 239 | test('should include etag property in response when updating note content', async () => { 240 | // Setup mock server to return an etag 241 | const mockEtag = 'W/"updated-etag"' 242 | 243 | server.use( 244 | http.patch('https://api.hackmd.io/v1/notes/test-note-id', () => { 245 | return HttpResponse.json( 246 | { 247 | id: 'test-note-id', 248 | title: 'Updated Test Note', 249 | content: 'Updated content via updateNote' 250 | }, 251 | { 252 | headers: { 253 | 'ETag': mockEtag 254 | } 255 | } 256 | ) 257 | }) 258 | ) 259 | 260 | // Make request with default unwrapData: true 261 | const response = await client.updateNoteContent('test-note-id', 'Updated content') 262 | 263 | // Verify response has etag property 264 | expect(response).toHaveProperty('etag', mockEtag) 265 | 266 | // Verify data properties still exist 267 | expect(response).toHaveProperty('id', 'test-note-id') 268 | expect(response).toHaveProperty('title', 'Updated Test Note') 269 | expect(response).toHaveProperty('content', 'Updated content via updateNote') 270 | }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /nodejs/tests/mock/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw' 2 | 3 | export const handlers = [ 4 | http.get('/posts', () => { 5 | return HttpResponse.json(null) 6 | }), 7 | ] 8 | -------------------------------------------------------------------------------- /nodejs/tests/mock/index.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw' 2 | import { setupServer } from 'msw/node' 3 | import { faker } from '@faker-js/faker' 4 | 5 | const checkBearerToken = (request: Request) => { 6 | const authHeader = request.headers.get('Authorization') 7 | const token = authHeader?.split(' ')[1] 8 | 9 | return token === process.env.HACKMD_ACCESS_TOKEN 10 | } 11 | 12 | // In MSW v2, we don't need the withAuthorization wrapper - we can handle auth directly in the handler 13 | export const server = setupServer( 14 | http.get('https://api.hackmd.io/v1/me', ({ request }) => { 15 | // Check authorization 16 | if (!checkBearerToken(request)) { 17 | return HttpResponse.json( 18 | { error: 'Unauthorized' }, 19 | { status: 401 } 20 | ) 21 | } 22 | 23 | // Return successful response with mock user data 24 | return HttpResponse.json({ 25 | id: faker.datatype.uuid(), 26 | name: faker.name.fullName(), 27 | email: faker.internet.email(), 28 | userPath: faker.internet.userName(), 29 | photo: faker.image.avatar(), 30 | teams: [] 31 | }) 32 | }), 33 | ) 34 | -------------------------------------------------------------------------------- /nodejs/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "outDir": "dist", 8 | "rootDir": ".", 9 | "strict": true, 10 | "target": "ESNext", 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /nodejs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ] 9 | } -------------------------------------------------------------------------------- /nodejs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "**/*" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------