From 51d87d7cbae22d6c083ce598a755b3f0fae931e5 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Sat, 9 Aug 2025 10:52:39 +0800 Subject: [PATCH 1/2] examples(book-mode-conference): add progress/resume support - Introduce a simple progress manager inside the example - Support RESUME_MODE env or --resume flag to continue - Persist per-session note creation and restore URLs on resume - Save progress after each note; include book URL on success --- examples/book-mode-conference/index.ts | 82 ++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/examples/book-mode-conference/index.ts b/examples/book-mode-conference/index.ts index 5c1b0e8..e113e3c 100644 --- a/examples/book-mode-conference/index.ts +++ b/examples/book-mode-conference/index.ts @@ -319,6 +319,57 @@ ${bookContent} // MAIN EXECUTION LOGIC // ========================================== +// Simple reusable progress manager +type ProgressState = { + completedSessions: string[] + sessionNotes: Record + mainBookCreated?: boolean + mainBookUrl?: string + startedAt?: string + completedAt?: string +} + +function createProgressManager(progressFilePath: string) { + const resolvedPath = path.resolve(progressFilePath) + + function load(): ProgressState | null { + if (!fs.existsSync(resolvedPath)) return null + try { + const data = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + return data + } catch (e: any) { + console.warn(`⚠️ Failed to load progress: ${e.message}`) + return null + } + } + + function initFresh(): ProgressState { + if (fs.existsSync(resolvedPath)) { + try { fs.unlinkSync(resolvedPath) } catch {} + } + return { + completedSessions: [], + sessionNotes: {}, + startedAt: new Date().toISOString(), + } + } + + function save(progress: ProgressState) { + try { fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) } catch {} + } + + function isSessionDone(id: string, p: ProgressState) { + return p.completedSessions.includes(id) + } + + function markSessionDone(id: string, noteUrl: string, p: ProgressState) { + if (!p.completedSessions.includes(id)) p.completedSessions.push(id) + p.sessionNotes[id] = noteUrl + } + + return { load, initFresh, save, isSessionDone, markSessionDone, progressFilePath: resolvedPath } +} + /** * Main function that orchestrates the entire book mode note creation process */ @@ -340,9 +391,32 @@ async function main(): Promise { const sessionList = loadAndProcessSessions() console.log(`Processing ${sessionList.length} sessions...`) + // Progress/resume support + const pm = createProgressManager(path.join(__dirname, 'progress.json')) + const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') + let progress: ProgressState | null = null + if (RESUME_MODE) { + progress = pm.load() + if (!progress) { + console.error('No progress.json found. Start without --resume to create it.') + process.exit(1) + } + console.log(`🔄 Resume mode: ${progress.completedSessions.length} sessions already created`) + } else { + progress = pm.initFresh() + console.log('🚀 Fresh run: progress initialized') + } + // Create individual session notes console.log('\n=== Creating Individual Session Notes ===') for (let data of sessionList) { + if (pm.isSessionDone(data.id, progress!)) { + // restore URL + if (progress!.sessionNotes[data.id]) data.noteUrl = progress!.sessionNotes[data.id].replace(`${getHackMDHost()}/`, '') + console.log(`⏭️ Skip existing: ${data.title}`) + continue + } + const noteContent = generateSessionNoteContent(data) const noteData = { @@ -355,6 +429,8 @@ async function main(): Promise { try { const note = await api.createTeamNote(TEAM_PATH, noteData) data.noteUrl = note.shortId + pm.markSessionDone(data.id, `${getHackMDHost()}/${note.shortId}`, progress!) + pm.save(progress!) console.log(`✓ Created note for: ${data.title}`) } catch (error: any) { console.error(`✗ Failed to create note for ${data.title}:`, error.message) @@ -395,6 +471,12 @@ async function main(): Promise { console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) console.log('\n🎉 Book mode conference notes created successfully!') console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) + if (progress) { + progress.mainBookCreated = true + progress.mainBookUrl = `${hackmdHost}/${mainBook.shortId}` + progress.completedAt = new Date().toISOString() + pm.save(progress) + } } catch (error: any) { console.error('✗ Failed to create main book:', error.message) } From 002948c757e4612537dcdbe6427477e78b5dc4e7 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Wed, 13 Aug 2025 09:33:18 +0900 Subject: [PATCH 2/2] feat: update the example --- examples/book-mode-conference/.env.example | 51 +- examples/book-mode-conference/README.md | 351 ++++++------ examples/book-mode-conference/index.ts | 532 ++++++++++++------ .../book-mode-conference/package-lock.json | 2 +- examples/book-mode-conference/sessions.json | 24 +- 5 files changed, 569 insertions(+), 391 deletions(-) diff --git a/examples/book-mode-conference/.env.example b/examples/book-mode-conference/.env.example index 5b7704b..ec9cee7 100644 --- a/examples/book-mode-conference/.env.example +++ b/examples/book-mode-conference/.env.example @@ -1,8 +1,49 @@ -# HackMD API Configuration -# Get your access token from: https://hackmd.io/@hackmd-api/developer-portal +# HackMD Conference Note Generation Environment Variables -# Required: Your HackMD access token +# Required: HackMD API Access Token +# Get this from your HackMD instance settings > API tokens +# For hackmd.io: https://hackmd.io/@hackmd-api/developer-portal HACKMD_ACCESS_TOKEN=your_access_token_here -# Optional: HackMD API endpoint (defaults to https://api.hackmd.io/v1) -HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 \ No newline at end of file +# Required: HackMD API Endpoint URL +# For hackmd.io: https://api.hackmd.io/v1 +# For self-hosted: https://your-hackmd-instance.com/api/v1 +HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 + +# Optional: HackMD Web Domain (for generating correct note URLs) +# This is useful when your API endpoint differs from the web domain +# For hackmd.io: https://hackmd.io +# For self-hosted: https://your-hackmd-instance.com +# If not set, defaults to the API endpoint +HACKMD_WEB_DOMAIN=https://hackmd.io + +# Optional: Test Mode +# Set to 'true' to create limited notes for testing +# Set to 'false' or omit for full note generation +TEST_MODE=false + +# Optional: Resume Mode +# Set to 'true' to resume from previous interrupted execution +# Set to 'false' or omit for fresh generation +RESUME_MODE=false + +# Optional: Fixed delay (milliseconds) between API requests +# Use to avoid rate limits in production environments +# Can also be set via --delay-ms CLI flag +# Recommended: 200-500ms for production +REQUEST_DELAY_MS=0 + +# Example configurations: +# +# For hackmd.io: +# HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 +# HACKMD_WEB_DOMAIN=https://hackmd.io +# +# For self-hosted HackMD: +# HACKMD_API_ENDPOINT=https://your-hackmd.example.com/api/v1 +# HACKMD_WEB_DOMAIN=https://your-hackmd.example.com +# +# Production environment example: +# TEST_MODE=false +# REQUEST_DELAY_MS=300 +# RESUME_MODE=false \ No newline at end of file diff --git a/examples/book-mode-conference/README.md b/examples/book-mode-conference/README.md index 5b5f20b..707a6b2 100644 --- a/examples/book-mode-conference/README.md +++ b/examples/book-mode-conference/README.md @@ -1,120 +1,84 @@ # Book Mode Conference Note Generator -This example demonstrates how to create a "book mode" conference note system using the HackMD API. Book mode is a Markdown note that contains organized links to each session note page, making it easy for conference attendees to navigate between different session notes. +This example demonstrates how to create a "book mode" conference note system using the HackMD API with resume functionality for production environments. ## What This Example Does -The script performs the following actions: - -1. **Loads Session Data**: Reads conference session information from `sessions.json` -2. **Creates Individual Session Notes**: For each session, creates a dedicated HackMD note with: +1. **Creates Individual Session Notes**: One note per session with: - Session title and speaker information + - Time, room, and session details - Embedded announcement note - - Sections for notes, discussion, and related links - - Appropriate tags and permissions -3. **Generates Main Book Note**: Creates a master note that: - - Contains welcome information and useful links - - Organizes all session notes by day and time - - Provides easy navigation to all sessions - - Serves as a central hub for the conference - -## Features - -- **TypeScript Implementation**: Written in TypeScript with full type safety -- **Configurable Constants**: All configuration is centralized at the top of the file -- **Comprehensive Comments**: Well-documented code explaining each section -- **Error Handling**: Graceful handling of API failures -- **tsx Support**: Can be run directly without compilation using tsx -- **Modular Design**: Functions are exportable for potential reuse -- **Flexible Session Data**: Supports various session types and multilingual content - -## Setup - -### Prerequisites - -- Node.js (version 16 or higher) -- A HackMD account with API access -- Access to a HackMD team (for creating team notes) - -### Installation + - Sections for notes, Q&A, and discussion -1. **Build the main HackMD API package** (if not already done): - ```bash - cd ../../nodejs - npm install - npm run build - cd ../examples/book-mode-conference - ``` +2. **Creates Main Book Note**: A master index that: + - Lists all session notes organized by day and time + - Provides easy navigation between sessions + - Serves as the conference note hub -2. **Install dependencies**: - ```bash - npm install - ``` +3. **Resume Functionality**: + - Saves progress automatically + - Can resume if interrupted (power outage, network issues, etc.) + - Tracks completed sessions to avoid duplicates -3. **Configure your HackMD access token**: - - **Option A: Environment Variable** - ```bash - # For Unix/Linux/macOS - export HACKMD_ACCESS_TOKEN=your_access_token_here - - # For Windows PowerShell - $env:HACKMD_ACCESS_TOKEN="your_access_token_here" - ``` +## Setup - **Option B: .env File** - ```bash - cp .env.example .env - # Edit .env and add your access token - ``` +### 1. Install Dependencies +```bash +cd /path/to/api-client/examples/book-mode-conference +npm install +``` - You can get your access token from the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). +### 2. Configure Environment +```bash +cp .env.example .env +# Edit .env with your settings +``` -### Configuration +Required `.env` settings: +```bash +HACKMD_ACCESS_TOKEN=your_access_token_here +HACKMD_API_ENDPOINT=https://api.hackmd.io/v1 +HACKMD_WEB_DOMAIN=https://hackmd.io +``` -Before running the script, you may want to customize the configuration constants at the top of `index.ts`: +### 3. Customize Configuration -#### Essential Configuration +Edit the constants at the top of `index.ts`: ```typescript // HackMD announcement note to embed in each session note -const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' // Team path where notes will be created -const TEAM_PATH = 'DevOpsDay' - -// Conference details -const CONFERENCE_CONFIG = { - name: 'DevOpsDays Taipei 2025', - website: 'https://devopsdays.tw/', - community: 'https://www.facebook.com/groups/DevOpsTaiwan/', - tags: 'DevOpsDays Taipei 2025' -} +const TEAM_PATH = 'TechConf' + +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' ``` -#### Session Data Format +### 4. Prepare Session Data -The script expects session data in `sessions.json` with the following structure: +Ensure `sessions.json` exists with your conference session data: ```json [ { "id": "session-001", - "title": "Session Title", + "title": "Opening Keynote: The Future of Technology", "speaker": [ { "speaker": { - "public_name": "Speaker Name" + "public_name": "John Doe" } } ], - "session_type": "talk", + "session_type": "keynote", "started_at": "2025-03-15T09:00:00Z", "finished_at": "2025-03-15T09:30:00Z", - "tags": ["tag1", "tag2"], + "tags": ["welcome", "keynote"], "classroom": { - "tw_name": "會議室名稱", - "en_name": "Room Name" + "tw_name": "主舞台", + "en_name": "Main Stage" }, "language": "en", "difficulty": "General" @@ -122,167 +86,180 @@ The script expects session data in `sessions.json` with the following structure: ] ``` -## Running the Example +## Usage -### Development Mode (with file watching) +### Test Mode (Recommended First) ```bash -npm run dev +# Creates only 3 sessions for testing +npx tsx index.ts --test ``` ### Production Mode ```bash -npm start +# Create all session notes +npx tsx index.ts + +# With rate limiting (recommended for large conferences) +npx tsx index.ts --delay-ms 300 ``` -### Direct Execution with tsx +### Resume Interrupted Execution ```bash -npx tsx index.ts -``` +# If the script was interrupted, resume from where it left off +npx tsx index.ts --resume -## Sample Session Data +# Resume with rate limiting +npx tsx index.ts --resume --delay-ms 500 +``` -The included `sessions.json` contains sample conference session data with: +### All Available Options +```bash +npx tsx index.ts [options] -- **Multiple session types**: keynotes, talks, workshops -- **Multi-day schedule**: Sessions across different days -- **Bilingual support**: English and Traditional Chinese sessions -- **Various difficulty levels**: General, Beginner, Intermediate, Advanced -- **Multiple speakers**: Examples of single and multiple speaker sessions +Options: + --test Test mode - create only first 3 sessions + --resume Resume from previous interrupted execution + --delay-ms Add delay (ms) between API requests + --help, -h Show help message +``` ## Generated Output -The script will create: +### Session Notes +Each session gets a note with this structure: +```markdown +# Session Title - Speaker Name -1. **Individual Session Notes**: Each with a dedicated HackMD note containing: - - Session title with speaker names - - Embedded announcement note - - Sections for collaborative note-taking - - Discussion area - - Related links +**Time:** 09:00 ~ 09:30 | **Room:** Main Stage -2. **Main Conference Book**: A master note containing: - - Conference welcome information - - Organized schedule with links to all session notes - - Quick navigation by day and time - - Useful conference resources +{%hackmd @TechConf/announcement-note-id %} -### Example Output +> ==投影片== +> (講者請在此放置投影片連結) -``` -=== Creating Individual Session Notes === -✓ Created note for: Welcome to DevOpsDays - John Doe -✓ Created note for: Introduction to CI/CD - Jane Smith -✓ Created note for: Advanced Kubernetes Operations - Alex Chen & Sarah Wilson -... +> ==Q & A== +> (講者 Q&A 相關連結) -=== Session URLs === -[ - { - "id": "session-001", - "url": "https://hackmd.io/abc123", - "title": "Welcome to DevOpsDays - John Doe" - }, - ... -] +## 📝 筆記區 +> 請從這裡開始記錄你的筆記 -=== Main Conference Book Created === -✓ Book URL: https://hackmd.io/xyz789 -🎉 Book mode conference notes created successfully! -📚 Main book contains links to 6 session notes +## ❓ Q&A 區域 +> 講者問答與現場互動 + +## 💬 討論區 +> 歡迎在此進行討論與交流 ``` -## Customization +### Main Book Note +The index book organizes sessions by day: +```markdown +TechConf 2025 共同筆記 +=== -### Modifying Note Templates +## 歡迎來到 TechConf 2025! -You can customize the session note template by modifying the `generateSessionNoteContent` function: +- [HackMD 快速入門](https://hackmd.io/s/BJvtP4zGX) +- [HackMD 會議功能介紹](https://hackmd.io/s/BJHWlNQMX) -```typescript -function generateSessionNoteContent(session: ProcessedSession): string { - return `# ${session.title} +## 議程筆記 -{%hackmd ${ANNOUNCEMENT_NOTE} %} +### 03/15 +- 09:00 ~ 09:30 [Opening Keynote: The Future of Technology - John Doe](/session-note-id) (Main Stage) +- 10:00 ~ 10:45 [Advanced Cloud Architecture - Jane Smith](/session-note-id) (Room A) +``` -## Your Custom Section -> Add your custom content here +## Resume Functionality -## ${SESSION_NOTE_CONFIG.sections.notes} -> ${SESSION_NOTE_CONFIG.sections.notesDescription} +The script automatically saves progress to `progress.json`: -// ... rest of template -` +```json +{ + "completedSessions": ["session-001", "session-002"], + "sessionNotes": { + "session-001": "https://hackmd.io/abc123", + "session-002": "https://hackmd.io/def456" + }, + "mainBookCreated": false, + "startedAt": "2025-01-15T10:00:00.000Z" } ``` -### Changing the Book Structure +### When to Use Resume -The book organization can be modified by changing the nesting keys in the main function: +Use `--resume` when: +- Script was interrupted (network issues, power outage, etc.) +- Hit API rate limits and need to continue later +- Want to add new sessions to existing conference notes -```typescript -// Current: organize by day, then by start time -const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) +### Resume Workflow + +```bash +# 1. Start generation +npx tsx index.ts --delay-ms 300 -// Alternative: organize by session type, then by day -const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['sessionType', 'day']) +# 2. Script fails after 50 sessions (network issue) +# 3. Wait a few minutes for rate limits to reset +# 4. Resume from session 51 +npx tsx index.ts --resume --delay-ms 400 ``` -### Adding Additional Metadata +## Troubleshooting -You can extend the session data structure and processing by: +### Environment Variable Issues -1. Adding new fields to the `ProcessedSession` interface -2. Updating the `loadAndProcessSessions` function to process new fields -3. Modifying the note templates to include the new information +Test if your `.env` file is loaded correctly: +```bash +node test-env.js +``` -## Error Handling +### Common Errors -The script includes comprehensive error handling: +**401 Authentication Error** +- Check `HACKMD_ACCESS_TOKEN` is correct +- Verify token has team permissions +- Ensure API endpoint is correct -- **Missing Environment Variables**: Clear error messages with setup instructions -- **Missing Session File**: Helpful error message with expected file location -- **API Failures**: Individual session note failures don't stop the entire process -- **Network Issues**: The HackMD API client includes built-in retry logic +**"Session file not found"** +- Ensure `sessions.json` exists in same directory +- Check JSON format is valid -## Troubleshooting +**"Failed to create note"** +- Check team permissions +- Verify `TEAM_PATH` is correct +- Check API quota limits -### Common Issues +### Manual Override -**"HACKMD_ACCESS_TOKEN environment variable is not set"** -- Solution: Set your access token using one of the methods in the Setup section +If `.env` isn't working: +```bash +export HACKMD_ACCESS_TOKEN=your_token_here +npx tsx index.ts --test +``` -**"Sessions file not found"** -- Solution: Ensure `sessions.json` exists in the same directory as `index.ts` +## Customization -**"Failed to create note for [session]"** -- Check your team permissions -- Verify the team path is correct -- Ensure your access token has team note creation permissions +The example is designed to be easily customizable: -**"Failed to create main book"** -- Same troubleshooting steps as individual notes -- Check that you have sufficient API quota remaining +### Session Note Template +Edit `generateSessionNoteContent()` function to change note structure. -### Development Tips +### Book Organization +Edit `generateBookContent()` function to change how sessions are grouped. -1. **Start Small**: Test with a few sessions first by modifying `sessions.json` -2. **Check Permissions**: Ensure your HackMD team allows note creation -3. **Monitor Rate Limits**: The script includes built-in retry logic, but be mindful of API limits -4. **Backup Data**: Consider backing up important notes before running the script +### Excluded Sessions +Edit `EXCLUDE_SESSIONS` array to filter out non-content sessions. -## API Features Demonstrated +### Conference Details +Change `CONFERENCE_NAME`, `TEAM_PATH`, and `ANNOUNCEMENT_NOTE` constants. -This example showcases several HackMD API features: +## Production Tips -- **Team Note Creation**: Creating notes within a team context -- **Permission Management**: Setting read/write permissions for notes -- **Content Templates**: Using consistent note structures -- **Bulk Operations**: Creating multiple notes programmatically -- **Error Handling**: Graceful handling of API errors +1. **Always test first**: Use `--test` to verify configuration +2. **Use rate limiting**: Add `--delay-ms 300` for large conferences +3. **Monitor progress**: Keep `progress.json` until completion +4. **Plan for interruptions**: Use `--resume` if anything goes wrong +5. **Check permissions**: Ensure your token can create team notes ## License -This example is part of the HackMD API client and is licensed under the MIT License. - -## Contributing - -If you have suggestions for improving this example or find bugs, please open an issue or submit a pull request to the main repository. \ No newline at end of file +This example is part of the HackMD API client and is licensed under the MIT License. \ No newline at end of file diff --git a/examples/book-mode-conference/index.ts b/examples/book-mode-conference/index.ts index e113e3c..579af29 100644 --- a/examples/book-mode-conference/index.ts +++ b/examples/book-mode-conference/index.ts @@ -1,13 +1,21 @@ #!/usr/bin/env tsx /** - * Book Mode Conference Note Generator - * - * This script generates a "book mode" conference note system using HackMD API. - * It creates individual notes for each session and a main book note that links to all sessions. - * - * Book mode is a Markdown note that contains organized links to each session note page, - * making it easy for conference attendees to navigate between different session notes. - * + * Production-Ready Book Mode Conference Note Generator + * + * This script generates a "book mode" conference note system using HackMD API with + * production-ready features including resume functionality, progress tracking, and + * comprehensive error handling. + * + * Based on proven patterns from large-scale conference implementations. + * + * Features: + * - Resume interrupted executions (--resume flag) + * - Progress tracking with automatic backups + * - Rate limiting and request delay controls + * - Comprehensive CLI help and configuration + * - Production-ready error handling + * - Test mode for safe development + * * Prerequisites: * - HackMD access token (set in HACKMD_ACCESS_TOKEN environment variable) * - Team path where notes will be created @@ -16,69 +24,106 @@ 'use strict' -// Load environment variables from .env file in project root +// Load environment variables from .env file import dotenv from 'dotenv' -dotenv.config() +import { fileURLToPath } from 'url' +import path from 'path' + +// Get the current directory for ES modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Load .env file from the same directory as this script +dotenv.config({ path: path.join(__dirname, '.env') }) import _ from 'lodash' import moment from 'moment' import { API } from '@hackmd/api' import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -// Get the current directory for ES modules -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) +// ========================================== +// CLI HELP AND ARGUMENT PARSING +// ========================================== + +if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(` +🎯 Production-Ready Conference Note Generator + +Usage: npx tsx index.ts [options] + +Options: + --test Test mode - create only first 3 sessions + --resume Resume from previous interrupted execution + --delay-ms Add a fixed delay (ms) between API requests + --help, -h Show this help message + +Environment Variables: + TEST_MODE=true|false Same as --test + RESUME_MODE=true|false Same as --resume + REQUEST_DELAY_MS=number Same as --delay-ms + HACKMD_ACCESS_TOKEN=token HackMD API token (required) + HACKMD_API_ENDPOINT=url HackMD API endpoint (optional) + HACKMD_WEB_DOMAIN=url HackMD web domain (optional) + +Resume Feature (Production Critical): + If the script fails during execution, it saves progress to progress.json. + Use --resume to continue from where it left off. + + Example production workflow: + 1. npx tsx index.ts --delay-ms 500 # Start with 500ms delay + 2. Script fails after 50 notes # Due to limits or network issues + 3. Wait 5-10 minutes # Let rate limits reset + 4. npx tsx index.ts --resume # Continue from note 51 + +Production Tips: + - Always use --delay-ms in production (recommend 200-500ms) + - Monitor API rate limits and adjust delays accordingly + - Keep progress.json file until completion for recovery + - Use --test first to validate configuration + +Examples: + npx tsx index.ts --test # Test with 3 sessions + npx tsx index.ts --delay-ms 300 # Production run with 300ms delay + npx tsx index.ts --resume --delay-ms 500 # Resume with 500ms delay +`) + process.exit(0) +} + +// Parse CLI arguments +const TEST_MODE = process.env.TEST_MODE === 'true' || process.argv.includes('--test') +const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') +const PROGRESS_FILE = path.join(__dirname, 'progress.json') + +// Parse request delay +const ENV_REQUEST_DELAY_MS = parseInt(process.env.REQUEST_DELAY_MS || '0', 10) +let CLI_REQUEST_DELAY_MS = ENV_REQUEST_DELAY_MS +const delayFlagIndex = process.argv.indexOf('--delay-ms') +if (delayFlagIndex !== -1 && process.argv[delayFlagIndex + 1]) { + const parsed = parseInt(process.argv[delayFlagIndex + 1], 10) + if (!Number.isNaN(parsed)) CLI_REQUEST_DELAY_MS = parsed +} // ========================================== // CONFIGURATION CONSTANTS // ========================================== -/** - * HackMD announcement note short ID to be embedded in each session note - * This note typically contains conference-wide announcements or information - */ -const ANNOUNCEMENT_NOTE = '@DevOpsDay/rkO2jyLMlg' +// ========================================== +// CONFIGURATION - CUSTOMIZE THESE VALUES +// ========================================== -/** - * Team path where all notes will be created - * This should be your HackMD team's unique identifier - */ -const TEAM_PATH = 'DevOpsDay' +// HackMD announcement note to embed in each session note +const ANNOUNCEMENT_NOTE = '@TechConf/announcement-note-id' -/** - * Conference details for the main book note - */ -const CONFERENCE_CONFIG = { - name: 'DevOpsDays Taipei 2025', - website: 'https://devopsdays.tw/', - community: 'https://www.facebook.com/groups/DevOpsTaiwan/', - tags: 'DevOpsDays Taipei 2025' -} +// Team path where notes will be created +const TEAM_PATH = 'TechConf' -/** - * Session note template configuration - */ -const SESSION_NOTE_CONFIG = { - // Default content sections for each session note - sections: { - notes: '筆記區', - notesDescription: '從這開始記錄你的筆記', - discussion: '討論區', - discussionDescription: '歡迎在此進行討論', - links: '相關連結' - } -} +// Conference name for titles and content +const CONFERENCE_NAME = 'TechConf 2025' -/** - * Main book note configuration - */ -const BOOK_NOTE_CONFIG = { - welcomeNote: '/@DevOpsDay/ry9DnJIfel', - hackmdQuickStart: 'https://hackmd.io/s/BJvtP4zGX', - hackmdMeetingFeatures: 'https://hackmd.io/s/BJHWlNQMX' -} +// Sessions to exclude from note generation (customize as needed) +const EXCLUDE_SESSIONS = [ + '報到時間', '開幕', '閉幕', 'Opening', 'Closing', 'Break', 'Lunch', '休息時間', '午餐' +] // ========================================== // TYPE DEFINITIONS @@ -90,7 +135,7 @@ const BOOK_NOTE_CONFIG = { */ const NotePermissionRole = { OWNER: 'owner', - SIGNED_IN: 'signed_in', + SIGNED_IN: 'signed_in', GUEST: 'guest' } as const @@ -153,14 +198,14 @@ interface SessionUrl { /** * Creates a nested object structure from an array using specified keys * This is used to organize sessions by day and time for the book structure - * + * * @param seq - Array of items to nest * @param keys - Array of property names to use for nesting levels * @returns Nested object structure */ function nest(seq: any[], keys: string[]): any { if (!keys.length) return seq - + const [first, ...rest] = keys return _.mapValues(_.groupBy(seq, first), function (value) { return nest(value, rest) @@ -168,89 +213,81 @@ function nest(seq: any[], keys: string[]): any { } /** - * Extracts the HackMD host URL from the API endpoint - * This is used to generate correct note URLs for display - * - * @returns The HackMD host URL - */ -function getHackMDHost(): string { - const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' - try { - const url = new URL(apiEndpoint) - return `${url.protocol}//${url.host}` - } catch (error) { - console.warn('Failed to parse HACKMD_API_ENDPOINT, falling back to https://hackmd.io') - return 'https://hackmd.io' - } -} - -/** - * Loads and processes session data from JSON file - * Filters out sessions with null session types and enriches data - * - * @returns Array of processed session data + * Load and process session data from JSON file */ function loadAndProcessSessions(): ProcessedSession[] { const sessionsPath = path.join(__dirname, 'sessions.json') - + if (!fs.existsSync(sessionsPath)) { throw new Error(`Sessions file not found: ${sessionsPath}`) } - + const rawSessions: RawSession[] = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')) - + return rawSessions - .filter(s => s.session_type && s.session_type !== null) // Filter out null session types + .filter(s => { + if (!s.session_type) return false + const title = (s.title || '').trim() + return !EXCLUDE_SESSIONS.includes(title) + }) .map(s => { - // Combine speaker names with ampersand separator - const speakers = s.speaker.map(speaker => { - return speaker.speaker.public_name - }).join(' & ') + const speakers = s.speaker.map(speaker => speaker.speaker.public_name).join('、') return { id: s.id, - title: s.title + (speakers ? " - " + speakers : ""), - tags: s.tags || [], + title: s.title + (speakers ? ` - ${speakers}` : ""), + tags: [CONFERENCE_NAME, ...(s.tags || [])], startDate: moment(s.started_at).valueOf(), day: moment(s.started_at).format('MM/DD'), startTime: moment(s.started_at).format('HH:mm'), endTime: moment(s.finished_at).format('HH:mm'), - sessionType: s.session_type!, // We already filtered out null values above + sessionType: s.session_type, classroom: s.classroom?.tw_name || s.classroom?.en_name || 'TBD', language: s.language || 'en', difficulty: s.difficulty || 'General' } }) - .sort((a, b) => (a.startDate - b.startDate)) // Sort by start time + .sort((a, b) => (a.startDate - b.startDate)) } /** - * Generates the content for a session note - * - * @param session - The session data - * @returns Formatted markdown content for the session note + * Generate content for a session note */ function generateSessionNoteContent(session: ProcessedSession): string { return `# ${session.title} +**Time:** ${session.startTime} ~ ${session.endTime} | **Room:** ${session.classroom} + {%hackmd ${ANNOUNCEMENT_NOTE} %} -## ${SESSION_NOTE_CONFIG.sections.notes} -> ${SESSION_NOTE_CONFIG.sections.notesDescription} +> ==投影片== +> (講者請在此放置投影片連結) + +> ==Q & A== +> (講者 Q&A 相關連結) + +## 📝 筆記區 +> 請從這裡開始記錄你的筆記 + + + +## ❓ Q&A 區域 +> 講者問答與現場互動 + + -## ${SESSION_NOTE_CONFIG.sections.discussion} -> ${SESSION_NOTE_CONFIG.sections.discussionDescription} +## 💬 討論區 +> 歡迎在此進行討論與交流 -## ${SESSION_NOTE_CONFIG.sections.links} -- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) -###### tags: \`${CONFERENCE_CONFIG.tags}\` + +###### tags: \`${CONFERENCE_NAME}\` ` } /** * Generates the hierarchical book content from nested session data - * + * * @param sessions - Nested session data organized by day/time * @param layer - Current nesting level (for header depth) * @returns Formatted markdown content for the book section @@ -285,33 +322,22 @@ function generateBookContent(sessions: any, layer: number): string { } /** - * Generates the main conference book note content - * - * @param bookContent - The hierarchical session content - * @returns Formatted markdown content for the main book note + * Generate the main conference book content */ function generateMainBookContent(bookContent: string): string { - return `${CONFERENCE_CONFIG.name} 共同筆記 + return `${CONFERENCE_NAME} 共同筆記 === -## 歡迎來到 ${CONFERENCE_CONFIG.name}! +## 歡迎來到 ${CONFERENCE_NAME}! -- [歡迎來到 DevOpsDays!](${BOOK_NOTE_CONFIG.welcomeNote}) -- [${CONFERENCE_CONFIG.name} 官方網站](${CONFERENCE_CONFIG.website}) [target=_blank] -- [HackMD 快速入門](${BOOK_NOTE_CONFIG.hackmdQuickStart}) -- [HackMD 會議功能介紹](${BOOK_NOTE_CONFIG.hackmdMeetingFeatures}) +- [HackMD 快速入門](https://hackmd.io/s/BJvtP4zGX) +- [HackMD 會議功能介紹](https://hackmd.io/s/BJHWlNQMX) ## 議程筆記 ${bookContent} -## 相關資源 - -- [DevOps Taiwan Community](${CONFERENCE_CONFIG.community}) -- [活動照片分享區](#) -- [問題回饋](#) - -###### tags: \`${CONFERENCE_CONFIG.tags}\` +###### tags: \`${CONFERENCE_NAME}\` ` } @@ -319,7 +345,7 @@ ${bookContent} // MAIN EXECUTION LOGIC // ========================================== -// Simple reusable progress manager +// Progress tracking for resume functionality type ProgressState = { completedSessions: string[] sessionNotes: Record @@ -336,6 +362,7 @@ function createProgressManager(progressFilePath: string) { if (!fs.existsSync(resolvedPath)) return null try { const data = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) + console.log(`📁 Loaded progress: ${data.completedSessions?.length || 0} sessions completed`) return data } catch (e: any) { console.warn(`⚠️ Failed to load progress: ${e.message}`) @@ -344,9 +371,6 @@ function createProgressManager(progressFilePath: string) { } function initFresh(): ProgressState { - if (fs.existsSync(resolvedPath)) { - try { fs.unlinkSync(resolvedPath) } catch {} - } return { completedSessions: [], sessionNotes: {}, @@ -355,7 +379,11 @@ function createProgressManager(progressFilePath: string) { } function save(progress: ProgressState) { - try { fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) } catch {} + try { + fs.writeFileSync(resolvedPath, JSON.stringify(progress, null, 2)) + } catch (e: any) { + console.warn(`⚠️ Failed to save progress: ${e.message}`) + } } function isSessionDone(id: string, p: ProgressState) { @@ -367,58 +395,136 @@ function createProgressManager(progressFilePath: string) { p.sessionNotes[id] = noteUrl } - return { load, initFresh, save, isSessionDone, markSessionDone, progressFilePath: resolvedPath } + function finalize(progress: ProgressState, options: { testMode?: boolean } = {}) { + if (!fs.existsSync(resolvedPath)) return + + progress.completedAt = new Date().toISOString() + save(progress) + + if (!options.testMode) { + try { + fs.unlinkSync(resolvedPath) + console.log(`\n🧹 Cleaned up progress file`) + } catch {} + } + } + + return { + load, + initFresh, + save, + isSessionDone, + markSessionDone, + finalize + } } /** * Main function that orchestrates the entire book mode note creation process + * Enhanced with production-ready features from proven conference implementations */ async function main(): Promise { + // Initialize API client configuration + const apiEndpoint = process.env.HACKMD_API_ENDPOINT || 'https://api.hackmd.io/v1' + const webDomain = process.env.HACKMD_WEB_DOMAIN || process.env.HACKMD_API_ENDPOINT || 'https://hackmd.io' + + console.log(`🚀 Starting ${CONFERENCE_NAME} note generation...`) + console.log(`📊 Configuration:`) + console.log(` Team: ${TEAM_PATH}`) + console.log(` API Endpoint: ${apiEndpoint}`) + console.log(` Test Mode: ${TEST_MODE}`) + console.log(` Resume Mode: ${RESUME_MODE}`) + console.log(` Request Delay: ${CLI_REQUEST_DELAY_MS}ms`) + // Validate required environment variables if (!process.env.HACKMD_ACCESS_TOKEN) { - console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.') + console.error('❌ Error: HACKMD_ACCESS_TOKEN environment variable is not set.') console.error('Please set your HackMD access token using one of these methods:') console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here') console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here') + console.error('3. Get your token from: https://hackmd.io/@hackmd-api/developer-portal') + process.exit(1) + } + + const apiOptions: any = { + wrapResponseErrors: true, + timeout: 60000 + } + + if (CLI_REQUEST_DELAY_MS > 0) { + apiOptions.retryConfig = { + maxRetries: 3, + baseDelay: CLI_REQUEST_DELAY_MS + } + } + + const api = new API(process.env.HACKMD_ACCESS_TOKEN!, apiEndpoint, apiOptions) + + // Verify authentication + try { + console.log('🔐 Verifying authentication...') + await api.getMe() + console.log(`✅ Authentication verified`) + } catch (error: any) { + console.error(`❌ Authentication failed: ${error.message}`) + console.error('Please check:') + console.error('1. Your HACKMD_ACCESS_TOKEN is correct') + console.error('2. Your token has the required permissions') + console.error('3. Your API endpoint is correct') + console.error('4. Your network connection to HackMD') process.exit(1) } - // Initialize API client - const api = new API(process.env.HACKMD_ACCESS_TOKEN, process.env.HACKMD_API_ENDPOINT) - // Load and process session data - console.log('Loading session data...') + console.log('📂 Loading session data...') const sessionList = loadAndProcessSessions() - console.log(`Processing ${sessionList.length} sessions...`) + console.log(`📊 Found ${sessionList.length} content sessions to process`) + + // Apply test mode filtering + if (TEST_MODE) { + console.log(`⚠️ TEST MODE: Processing only first 3 sessions`) + sessionList.splice(3) + } // Progress/resume support - const pm = createProgressManager(path.join(__dirname, 'progress.json')) - const RESUME_MODE = process.env.RESUME_MODE === 'true' || process.argv.includes('--resume') - let progress: ProgressState | null = null + const pm = createProgressManager(PROGRESS_FILE) + let progress: ProgressState + if (RESUME_MODE) { - progress = pm.load() - if (!progress) { - console.error('No progress.json found. Start without --resume to create it.') + const loadedProgress = pm.load() + if (!loadedProgress) { + console.error('❌ No progress.json found. Start without --resume to create it.') process.exit(1) } + progress = loadedProgress console.log(`🔄 Resume mode: ${progress.completedSessions.length} sessions already created`) + if (progress.failedSessions?.length) { + console.log(`⚠️ ${progress.failedSessions.length} sessions previously failed`) + } } else { progress = pm.initFresh() + progress.totalSessions = sessionList.length console.log('🚀 Fresh run: progress initialized') } // Create individual session notes - console.log('\n=== Creating Individual Session Notes ===') - for (let data of sessionList) { - if (pm.isSessionDone(data.id, progress!)) { - // restore URL - if (progress!.sessionNotes[data.id]) data.noteUrl = progress!.sessionNotes[data.id].replace(`${getHackMDHost()}/`, '') - console.log(`⏭️ Skip existing: ${data.title}`) + console.log('\n📝 Creating individual session notes...') + let processedCount = 0 + let skippedCount = 0 + + for (const data of sessionList) { + if (pm.isSessionDone(data.id, progress)) { + // Restore URL from progress + if (progress.sessionNotes[data.id]) { + data.noteUrl = progress.sessionNotes[data.id].replace(`${webDomain}/`, '') + } + console.log(`✅ Session "${data.title}" already completed, skipping`) + skippedCount++ continue } const noteContent = generateSessionNoteContent(data) - + const noteData = { title: data.title, content: noteContent, @@ -427,59 +533,100 @@ async function main(): Promise { } try { + console.log(`📝 Creating note for: ${data.title}`) const note = await api.createTeamNote(TEAM_PATH, noteData) data.noteUrl = note.shortId - pm.markSessionDone(data.id, `${getHackMDHost()}/${note.shortId}`, progress!) - pm.save(progress!) - console.log(`✓ Created note for: ${data.title}`) + + const noteUrl = `${webDomain}/${note.shortId}` + pm.markSessionDone(data.id, noteUrl, progress) + processedCount++ + + // Save progress every 5 sessions + if (processedCount % 5 === 0) { + pm.save(progress) + console.log(`💾 Progress saved (${processedCount} sessions processed)`) + } + + console.log(`✅ Created: ${noteUrl}`) + + // Add delay between requests if configured + if (CLI_REQUEST_DELAY_MS > 0) { + await new Promise(resolve => setTimeout(resolve, CLI_REQUEST_DELAY_MS)) + } + } catch (error: any) { - console.error(`✗ Failed to create note for ${data.title}:`, error.message) + console.error(`❌ Failed to create note for "${data.title}": ${error.message}`) data.noteUrl = 'error' + pm.save(progress) } } - // Output session URLs for reference - const hackmdHost = getHackMDHost() - const sessionUrls: SessionUrl[] = sessionList - .filter(s => s.noteUrl !== 'error') - .map(s => ({ - id: s.id, - url: `${hackmdHost}/${s.noteUrl}`, - title: s.title - })) - - console.log('\n=== Session URLs ===') - console.log(JSON.stringify(sessionUrls, null, 2)) - - // Create nested structure for the main book - const nestedSessions = nest(sessionList.filter(s => s.noteUrl !== 'error'), ['day', 'startTime']) - const bookContent = generateBookContent(nestedSessions, 1) + // Final progress save + pm.save(progress) + console.log(`✅ Session notes creation completed (${processedCount} new notes, ${skippedCount} skipped)`) - // Create main conference book - console.log('\n=== Creating Main Conference Book ===') - const mainBookContent = generateMainBookContent(bookContent) + // Create main conference book if not already created + if (progress.mainBookCreated) { + console.log(`\n✅ Main book already created: ${progress.mainBookUrl}`) + } else { + console.log('\n📚 Creating main conference book...') - try { - const mainBook = await api.createTeamNote(TEAM_PATH, { - title: `${CONFERENCE_CONFIG.name} 共同筆記`, - content: mainBookContent, - readPermission: NotePermissionRole.GUEST as any, - writePermission: NotePermissionRole.SIGNED_IN as any - }) + // Filter successful sessions for the book + const successfulSessions = sessionList.filter(s => s.noteUrl && s.noteUrl !== 'error') + const nestedSessions = nest(successfulSessions, ['day', 'startTime']) + const bookContent = generateBookContent(nestedSessions, 1) + const mainBookContent = generateMainBookContent(bookContent) - console.log('\n=== Main Conference Book Created ===') - console.log(`✓ Book URL: ${hackmdHost}/${mainBook.shortId}`) - console.log('\n🎉 Book mode conference notes created successfully!') - console.log(`📚 Main book contains links to ${sessionUrls.length} session notes`) - if (progress) { + try { + const mainBook = await api.createTeamNote(TEAM_PATH, { + title: `${CONFERENCE_NAME} 共同筆記`, + content: mainBookContent, + readPermission: NotePermissionRole.GUEST as any, + writePermission: NotePermissionRole.SIGNED_IN as any + }) + + const mainBookUrl = `${webDomain}/${mainBook.shortId}` progress.mainBookCreated = true - progress.mainBookUrl = `${hackmdHost}/${mainBook.shortId}` - progress.completedAt = new Date().toISOString() + progress.mainBookUrl = mainBookUrl pm.save(progress) + + console.log(`✅ Main book created: ${mainBookUrl}`) + } catch (error: any) { + console.error(`❌ Failed to create main book: ${error.message}`) } - } catch (error: any) { - console.error('✗ Failed to create main book:', error.message) } + + // Final statistics and cleanup + const successfulSessions = sessionList.filter(s => s.noteUrl && s.noteUrl !== 'error') + const failedSessions = sessionList.filter(s => s.noteUrl === 'error') + + console.log(`\n🎉 Generation completed!`) + console.log(`📚 Main book: ${progress.mainBookUrl || 'Failed to create'}`) + console.log(`📊 Statistics:`) + console.log(` ✅ Successful sessions: ${successfulSessions.length}`) + console.log(` ❌ Failed sessions: ${failedSessions.length}`) + console.log(` 📝 Total sessions processed: ${sessionList.length}`) + + if (failedSessions.length > 0) { + console.log(`\n⚠️ Failed sessions:`) + failedSessions.forEach(s => console.log(` - ${s.title}`)) + console.log(`\nTo retry failed sessions, fix any issues and run with --resume flag.`) + } + + // Output session URLs for reference + if (successfulSessions.length > 0) { + const sessionUrls: SessionUrl[] = successfulSessions.map(s => ({ + id: s.id, + url: `${webDomain}/${s.noteUrl}`, + title: s.title + })) + + console.log('\n📋 Session URLs:') + sessionUrls.forEach(s => console.log(` ${s.title}: ${s.url}`)) + } + + // Finalize progress + pm.finalize(progress, { testMode: TEST_MODE }) } // ========================================== @@ -488,8 +635,21 @@ async function main(): Promise { // Run the script when executed directly if (import.meta.url === `file://${process.argv[1]}`) { - main().catch(console.error) + main().catch((error) => { + console.error('\n💥 Generation failed:', error) + console.error('\nTroubleshooting:') + console.error('1. Check your HACKMD_ACCESS_TOKEN is valid') + console.error('2. Verify team permissions for note creation') + console.error('3. Check network connectivity') + console.error('4. Try running with --test first') + console.error('5. Use --resume to continue from last successful point') + console.error('\nFor production environments:') + console.error('- Use --delay-ms to avoid rate limits') + console.error('- Monitor progress.json for recovery') + console.error('- Check API quota limits') + process.exit(1) + }) } // Export functions for potential module usage -export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } \ No newline at end of file +export { main, generateBookContent, loadAndProcessSessions, generateSessionNoteContent } diff --git a/examples/book-mode-conference/package-lock.json b/examples/book-mode-conference/package-lock.json index 839b6fc..62815fe 100644 --- a/examples/book-mode-conference/package-lock.json +++ b/examples/book-mode-conference/package-lock.json @@ -22,7 +22,7 @@ }, "../../nodejs": { "name": "@hackmd/api", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "dependencies": { "axios": "^1.8.4", diff --git a/examples/book-mode-conference/sessions.json b/examples/book-mode-conference/sessions.json index 829ad2f..d9a7d36 100644 --- a/examples/book-mode-conference/sessions.json +++ b/examples/book-mode-conference/sessions.json @@ -1,7 +1,7 @@ [ { "id": "session-001", - "title": "Welcome to DevOpsDays", + "title": "Opening Keynote: The Future of Technology", "speaker": [ { "speaker": { @@ -12,7 +12,7 @@ "session_type": "keynote", "started_at": "2025-03-15T09:00:00Z", "finished_at": "2025-03-15T09:30:00Z", - "tags": ["welcome", "keynote"], + "tags": ["keynote", "future", "technology"], "classroom": { "tw_name": "主舞台", "en_name": "Main Stage" @@ -22,7 +22,7 @@ }, { "id": "session-002", - "title": "Introduction to CI/CD", + "title": "Advanced Cloud Architecture", "speaker": [ { "speaker": { @@ -33,7 +33,7 @@ "session_type": "talk", "started_at": "2025-03-15T10:00:00Z", "finished_at": "2025-03-15T10:45:00Z", - "tags": ["ci", "cd", "automation"], + "tags": ["cloud", "architecture", "scalability"], "classroom": { "tw_name": "A會議室", "en_name": "Room A" @@ -43,7 +43,7 @@ }, { "id": "session-003", - "title": "Advanced Kubernetes Operations", + "title": "Machine Learning in Production", "speaker": [ { "speaker": { @@ -59,7 +59,7 @@ "session_type": "workshop", "started_at": "2025-03-15T11:00:00Z", "finished_at": "2025-03-15T12:00:00Z", - "tags": ["kubernetes", "containers", "orchestration"], + "tags": ["ml", "ai", "production"], "classroom": { "tw_name": "B會議室", "en_name": "Room B" @@ -69,7 +69,7 @@ }, { "id": "session-004", - "title": "DevOps Culture and Practices", + "title": "Microservices Design Patterns", "speaker": [ { "speaker": { @@ -80,7 +80,7 @@ "session_type": "talk", "started_at": "2025-03-15T14:00:00Z", "finished_at": "2025-03-15T14:45:00Z", - "tags": ["culture", "practices", "team"], + "tags": ["microservices", "design", "patterns"], "classroom": { "tw_name": "主舞台", "en_name": "Main Stage" @@ -90,7 +90,7 @@ }, { "id": "session-005", - "title": "監控與可觀測性", + "title": "資料科學實務應用", "speaker": [ { "speaker": { @@ -101,7 +101,7 @@ "session_type": "talk", "started_at": "2025-03-16T09:30:00Z", "finished_at": "2025-03-16T10:15:00Z", - "tags": ["monitoring", "observability"], + "tags": ["data-science", "analytics"], "classroom": { "tw_name": "A會議室", "en_name": "Room A" @@ -111,7 +111,7 @@ }, { "id": "session-006", - "title": "Security in DevOps Pipeline", + "title": "Cybersecurity Best Practices", "speaker": [ { "speaker": { @@ -122,7 +122,7 @@ "session_type": "workshop", "started_at": "2025-03-16T10:30:00Z", "finished_at": "2025-03-16T12:00:00Z", - "tags": ["security", "devsecops", "pipeline"], + "tags": ["security", "cybersecurity", "best-practices"], "classroom": { "tw_name": "C會議室", "en_name": "Room C"