canvaslms-cli 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Canvas CLI Tool
3
+ Copyright (c) 2025 Dang Duy Toan
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,182 +1,106 @@
1
1
  # Canvas CLI Tool
2
2
 
3
- A powerful command-line interface for interacting with Canvas LMS API. This tool allows you to manage courses, assignments, submissions, and more directly from your terminal.
3
+ A modern, user-friendly command-line interface for Canvas LMS. Manage courses, assignments, submissions, grades, and more directly from your terminal.
4
+
5
+ ---
4
6
 
5
7
  ## Features
6
8
 
7
- - 📚 **Course Management**: List starred and enrolled courses
8
- - 📝 **Assignment Operations**: View assignments, grades, and submission status
9
- - 🚀 **File Submission**: Interactive file upload for assignments (single or multiple files)
10
- - 📢 **Announcements**: View course announcements
11
- - 👤 **Profile Management**: View user profile information
12
- - 🔧 **Raw API Access**: Direct access to Canvas API endpoints
9
+ - List and filter enrolled/starred courses
10
+ - View assignments, grades, and submission status
11
+ - Interactive file upload for assignments
12
+ - View course announcements
13
+ - Display user profile information
14
+ - Direct access to Canvas API endpoints
15
+
16
+ ---
13
17
 
14
18
  ## Installation
15
19
 
16
- ### Global Installation (Recommended)
20
+ ### Global (Recommended)
17
21
 
18
22
  ```bash
19
23
  npm install -g canvas-cli-tool
20
24
  ```
21
25
 
22
- ### Local Installation
26
+ ### Local (Project)
23
27
 
24
28
  ```bash
25
29
  npm install canvas-cli-tool
26
30
  ```
27
31
 
32
+ ---
33
+
28
34
  ## Setup
29
35
 
30
- 1. **Get your Canvas API token**:
31
- - Log into your Canvas instance
36
+ 1. **Get your Canvas API Token**
37
+ - Log in to Canvas
32
38
  - Go to Account → Settings
33
- - Scroll down to "Approved Integrations"
34
- - Click "+ New Access Token"
39
+ - Under Approved Integrations, click + New Access Token
35
40
  - Copy the generated token
36
41
 
37
- 2. **Configure the CLI**:
42
+ 2. **Configure the CLI**
43
+
38
44
  ```bash
39
45
  canvas config
40
46
  ```
41
47
 
42
- 3. **Create a `.env` file** in your project root:
48
+ 3. **Environment Variables**
49
+
50
+ Create a `.env` file in your project root:
51
+
43
52
  ```env
44
53
  CANVAS_DOMAIN=your-canvas-domain.instructure.com
45
54
  CANVAS_API_TOKEN=your-api-token
46
55
  ```
47
56
 
48
- ## Usage
49
-
50
- ### Basic Commands
51
-
52
- ```bash
53
- # Show configuration help
54
- canvas config
55
-
56
- # List starred courses (default)
57
- canvas list
58
-
59
- # List all enrolled courses
60
- canvas list -a
61
-
62
- # List courses with detailed information
63
- canvas list -v
64
-
65
- # Show user profile
66
- canvas profile
67
-
68
- # Show detailed profile information
69
- canvas profile -v
70
- ```
71
-
72
- ### Assignment Operations
73
-
74
- ```bash
75
- # List assignments for a course
76
- canvas assignments 12345
77
-
78
- # Show detailed assignment information
79
- canvas assignments 12345 -v
57
+ ---
80
58
 
81
- # Show only submitted assignments
82
- canvas assignments 12345 -s
83
-
84
- # Show only pending assignments
85
- canvas assignments 12345 -p
86
- ```
87
-
88
- ### File Submission
89
-
90
- ```bash
91
- # Interactive assignment submission
92
- canvas submit
93
-
94
- # Submit with specific course ID
95
- canvas submit -c 12345
96
-
97
- # Submit with specific assignment ID
98
- canvas submit -a 67890
99
-
100
- # Submit specific file
101
- canvas submit -f myfile.pdf
102
- ```
59
+ ## Usage
103
60
 
104
- ### Grades and Announcements
61
+ ### Common Commands
105
62
 
106
63
  ```bash
107
- # Show grades for all courses
108
- canvas grades
109
-
110
- # Show grades for specific course
111
- canvas grades 12345
112
-
113
- # Show recent announcements
114
- canvas announcements
115
-
116
- # Show announcements for specific course
117
- canvas announcements 12345
64
+ canvas config # Configure domain and API token
65
+ canvas list # List starred courses
66
+ canvas list -a # List all enrolled courses
67
+ canvas assignments <course> # List assignments for a course
68
+ canvas grades # Show grades for all courses
69
+ canvas announcements # Show recent announcements
70
+ canvas profile # Show user profile
71
+ canvas submit # Interactive assignment submission
118
72
  ```
119
73
 
120
- ### Raw API Access
74
+ ### Assignment Submission
121
75
 
122
76
  ```bash
123
- # GET request
124
- canvas get users/self
125
-
126
- # GET with query parameters
127
- canvas get courses -q enrollment_state=active
128
-
129
- # POST request with data
130
- canvas post courses/123/assignments -d '{"assignment": {"name": "Test"}}'
131
-
132
- # POST with data from file
133
- canvas post courses/123/assignments -d @assignment.json
77
+ canvas submit # Interactive mode
78
+ canvas submit -c <courseId> # Specify course
79
+ canvas submit -a <assignmentId> # Specify assignment
80
+ canvas submit -f <file> # Submit specific file
134
81
  ```
135
82
 
136
83
  ## Command Reference
137
84
 
138
- | Command | Alias | Description |
139
- |---------|-------|-------------|
140
- | `list` | `l` | List courses |
141
- | `assignments` | `assign` | List assignments |
142
- | `submit` | `sub` | Submit assignment files |
143
- | `grades` | `grade` | Show grades |
144
- | `announcements` | `announce` | Show announcements |
145
- | `profile` | `me` | Show user profile |
146
- | `config` | - | Show configuration |
147
- | `get` | `g` | GET API request |
148
- | `post` | `p` | POST API request |
149
- | `put` | - | PUT API request |
150
- | `delete` | `d` | DELETE API request |
151
-
152
- ## File Structure
85
+ | Command | Alias | Description |
86
+ |-----------------|-----------|-----------------------------------|
87
+ | `list` | `l` | List courses |
88
+ | `assignments` | `assign` | List assignments |
89
+ | `submit` | `sub` | Submit assignment files |
90
+ | `grades` | `grade` | Show grades |
91
+ | `announcements` | `announce`| Show announcements |
92
+ | `profile` | `me` | Show user profile |
93
+ | `config` | - | Show configuration |
153
94
 
154
- ```
155
- canvas-cli-tool/
156
- ├── src/
157
- │ └── index.js # Main CLI entry point
158
- ├── lib/
159
- │ ├── api-client.js # Canvas API client
160
- │ ├── config.js # Configuration management
161
- │ ├── file-upload.js # File upload utilities
162
- │ └── interactive.js # Interactive prompt utilities
163
- ├── commands/
164
- │ ├── list.js # List courses command
165
- │ ├── assignments.js # Assignments command
166
- │ ├── submit.js # Submit command
167
- │ ├── grades.js # Grades command
168
- │ ├── announcements.js # Announcements command
169
- │ ├── profile.js # Profile command
170
- │ ├── config.js # Config command
171
- │ └── api.js # Raw API commands
172
- └── package.json
173
- ```
95
+ ---
174
96
 
175
97
  ## Requirements
176
98
 
177
- - Node.js >= 14.0.0
178
- - npm >= 6.0.0
179
- - Valid Canvas LMS access with API token
99
+ - Node.js >= 14.x
100
+ - npm >= 6.x
101
+ - Valid Canvas LMS API token
102
+
103
+ ---
180
104
 
181
105
  ## Contributing
182
106
 
@@ -184,10 +108,12 @@ canvas-cli-tool/
184
108
  2. Create a feature branch: `git checkout -b feature-name`
185
109
  3. Make your changes
186
110
  4. Run tests: `npm test`
187
- 5. Commit your changes: `git commit -am 'Add feature'`
188
- 6. Push to the branch: `git push origin feature-name`
111
+ 5. Commit: `git commit -am 'Add feature'`
112
+ 6. Push: `git push origin feature-name`
189
113
  7. Submit a pull request
190
114
 
115
+ ---
116
+
191
117
  ## License
192
118
 
193
- MIT License - see [LICENSE](LICENSE) file for details.
119
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -5,7 +5,7 @@
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { makeCanvasRequest } from '../lib/api-client.js';
8
- import { createReadlineInterface, askQuestion, askConfirmation, selectFilesImproved, selectFilesInteractive, pad } from '../lib/interactive.js';
8
+ import { createReadlineInterface, askQuestion, askConfirmation, selectFilesImproved, selectFilesKeyboard, pad } from '../lib/interactive.js';
9
9
  import { uploadSingleFileToCanvas, submitAssignmentWithFiles } from '../lib/file-upload.js';
10
10
  import chalk from 'chalk';
11
11
 
@@ -137,16 +137,17 @@ export async function submitAssignment(options) {
137
137
  return;
138
138
  }
139
139
  assignmentId = selectedAssignment.id;
140
- console.log(chalk.green(`Success: Selected ${selectedAssignment.name}\n`));
141
- if (selectedAssignment.submission && selectedAssignment.submission.submitted_at) {
142
- const resubmit = await askConfirmation(rl, chalk.yellow('This assignment has already been submitted. Do you want to resubmit?'), false);
140
+ console.log(chalk.green(`Success: Selected ${selectedAssignment.name}\n`)); if (selectedAssignment.submission && selectedAssignment.submission.submitted_at) {
141
+ const resubmit = await askConfirmation(rl, chalk.yellow('This assignment has already been submitted. Do you want to resubmit?'), true);
143
142
  if (!resubmit) {
144
143
  console.log(chalk.yellow('Submission cancelled.'));
145
144
  rl.close();
146
145
  return;
147
146
  }
148
147
  }
149
- } // Step 3: Choose file selection method and select files
148
+ }
149
+
150
+ // Step 3: Choose file selection method and select files
150
151
  let filePaths = [];
151
152
  console.log(chalk.cyan.bold('-'.repeat(60)));
152
153
  console.log(chalk.cyan.bold('File Selection Method'));
@@ -155,7 +156,7 @@ export async function submitAssignment(options) {
155
156
  console.log(chalk.white('Assignment: ') + chalk.bold(selectedAssignment.name) + '\n');
156
157
 
157
158
  console.log(chalk.yellow('📁 Choose file selection method:'));
158
- console.log(chalk.white('1. ') + chalk.cyan('Enhanced Interactive Selector') + chalk.gray(' (Recommended - Visual browser with search, filters, and navigation)'));
159
+ console.log(chalk.white('1. ') + chalk.cyan('Keyboard Navigator') + chalk.gray(' (NEW! - Use arrow keys and space bar to navigate and select)'));
159
160
  console.log(chalk.white('2. ') + chalk.cyan('Text-based Selector') + chalk.gray(' (Traditional - Type filenames and wildcards)'));
160
161
  console.log(chalk.white('3. ') + chalk.cyan('Basic Directory Listing') + chalk.gray(' (Simple - Select from numbered list)'));
161
162
 
@@ -166,7 +167,7 @@ export async function submitAssignment(options) {
166
167
  console.log(chalk.cyan('-'.repeat(60)));
167
168
 
168
169
  if (selectorChoice === '1') {
169
- filePaths = await selectFilesInteractive(rl);
170
+ filePaths = await selectFilesKeyboard(rl);
170
171
  } else if (selectorChoice === '2') {
171
172
  filePaths = await selectFilesImproved(rl);
172
173
  } else {
@@ -8,6 +8,19 @@ import path from 'path';
8
8
  import AdmZip from 'adm-zip';
9
9
  import chalk from 'chalk';
10
10
 
11
+ // Key codes for raw terminal input
12
+ const KEYS = {
13
+ UP: '\u001b[A',
14
+ DOWN: '\u001b[B',
15
+ LEFT: '\u001b[D',
16
+ RIGHT: '\u001b[C',
17
+ SPACE: ' ',
18
+ ENTER: '\r',
19
+ ESCAPE: '\u001b',
20
+ BACKSPACE: '\u007f',
21
+ TAB: '\t'
22
+ };
23
+
11
24
  /**
12
25
  * Create readline interface for user input
13
26
  */
@@ -51,15 +64,26 @@ function askQuestionWithValidation(rl, question, validator, errorMessage) {
51
64
  * Ask for confirmation (Y/n format)
52
65
  */
53
66
  async function askConfirmation(rl, question, defaultYes = true) {
54
- const suffix = defaultYes ? ' (Y/n)' : ' (y/N)';
55
- const answer = await askQuestion(rl, question + suffix + ': ');
67
+ const suffix = defaultYes ? " (Y/n)" : " (y/N)";
68
+ const answer = await askQuestion(rl, question + suffix + ": ");
69
+
70
+ const lower = answer.toLowerCase();
56
71
 
57
- if (answer.trim() === '') {
72
+ // If user presses Enter → return defaultYes (true by default)
73
+ if (lower === "") {
58
74
  return defaultYes;
59
75
  }
60
-
61
- const lower = answer.toLowerCase();
62
- return lower === 'y' || lower === 'yes';
76
+
77
+ // Convert input to boolean
78
+ if (lower === "y" || lower === "yes") {
79
+ return true;
80
+ }
81
+ if (lower === "n" || lower === "no") {
82
+ return false;
83
+ }
84
+
85
+ // If input is something else, fallback to defaultYes
86
+ return defaultYes;
63
87
  }
64
88
 
65
89
  /**
@@ -360,58 +384,72 @@ async function selectFilesImproved(rl, currentDir = process.cwd()) {
360
384
  }
361
385
 
362
386
  /**
363
- * Enhanced interactive file selector with real-time preview and navigation
387
+ * Interactive file selector with tree view and keyboard navigation
364
388
  */
365
- async function selectFilesInteractive(rl, currentDir = process.cwd()) {
389
+ async function selectFilesKeyboard(rl, currentDir = process.cwd()) {
366
390
  const selectedFiles = [];
367
- let currentPath = currentDir;
391
+ let expandedFolders = new Set();
392
+ let fileTree = [];
368
393
  let fileList = [];
369
- let currentPage = 0;
370
- const itemsPerPage = 10;
394
+ let currentPath = currentDir;
395
+ let currentIndex = 0;
396
+ let isNavigating = true;
397
+ let viewStartIndex = 0;
398
+ const maxVisibleItems = 15;
371
399
 
372
- // Helper function to refresh file list
373
- function refreshFileList() {
400
+ // Setup raw mode for keyboard input
401
+ process.stdin.setRawMode(true);
402
+ process.stdin.resume();
403
+ process.stdin.setEncoding('utf8');
404
+
405
+ // Helper function to build file tree
406
+ function buildFileTree(basePath = currentDir, level = 0, parentPath = '') {
407
+ const tree = [];
374
408
  try {
375
- const entries = fs.readdirSync(currentPath);
376
- fileList = [];
377
-
378
- // Add parent directory option if not at root
379
- if (currentPath !== currentDir) {
380
- fileList.push({
381
- name: '📁 .. (Parent Directory)',
382
- path: path.dirname(currentPath),
383
- type: 'parent',
384
- size: 0
385
- });
386
- }
409
+ const entries = fs.readdirSync(basePath).sort();
387
410
 
388
411
  // Add directories first
389
412
  entries.forEach(entry => {
390
- const fullPath = path.join(currentPath, entry);
413
+ const fullPath = path.join(basePath, entry);
414
+ const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
415
+
391
416
  try {
392
417
  const stats = fs.statSync(fullPath);
393
- if (stats.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.vscode'].includes(entry)) {
394
- fileList.push({
395
- name: `📁 ${entry}`,
418
+ if (stats.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.vscode', '.next'].includes(entry)) {
419
+ const isExpanded = expandedFolders.has(fullPath);
420
+ tree.push({
421
+ name: entry,
396
422
  path: fullPath,
423
+ relativePath,
397
424
  type: 'directory',
425
+ level,
426
+ isExpanded,
398
427
  size: 0
399
428
  });
429
+
430
+ // If expanded, add children
431
+ if (isExpanded) {
432
+ const children = buildFileTree(fullPath, level + 1, relativePath);
433
+ tree.push(...children);
434
+ }
400
435
  }
401
436
  } catch (e) {}
402
437
  });
403
438
 
404
439
  // Add files
405
440
  entries.forEach(entry => {
406
- const fullPath = path.join(currentPath, entry);
441
+ const fullPath = path.join(basePath, entry);
442
+ const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
443
+
407
444
  try {
408
445
  const stats = fs.statSync(fullPath);
409
446
  if (stats.isFile()) {
410
- const icon = getFileIcon(entry);
411
- fileList.push({
412
- name: `${icon} ${entry}`,
447
+ tree.push({
448
+ name: entry,
413
449
  path: fullPath,
450
+ relativePath,
414
451
  type: 'file',
452
+ level,
415
453
  size: stats.size
416
454
  });
417
455
  }
@@ -420,6 +458,8 @@ async function selectFilesInteractive(rl, currentDir = process.cwd()) {
420
458
  } catch (error) {
421
459
  console.log(chalk.red('Error reading directory: ' + error.message));
422
460
  }
461
+
462
+ return tree;
423
463
  }
424
464
 
425
465
  // Helper function to get file icon based on extension
@@ -437,19 +477,45 @@ async function selectFilesInteractive(rl, currentDir = process.cwd()) {
437
477
  };
438
478
  return icons[ext] || '📋';
439
479
  }
480
+ // Helper function to build breadcrumb path
481
+ function buildBreadcrumb() {
482
+ const relativePath = path.relative(currentDir, currentPath);
483
+ if (!relativePath || relativePath === '.') {
484
+ return ''
485
+ }
486
+
487
+ const parts = relativePath.split(path.sep);
488
+ const breadcrumb = parts.map((part, index) => {
489
+ if (index === parts.length - 1) {
490
+ return chalk.white.bold(part);
491
+ }
492
+ return chalk.gray(part);
493
+ }).join(chalk.gray(' › '));
494
+
495
+ return chalk.yellow('📂 ') + breadcrumb;
496
+ }
440
497
 
441
- // Helper function to display current page
442
- function displayPage() {
443
- console.clear();
444
- console.log(chalk.cyan.bold('╭' + '─'.repeat(78) + '╮'));
445
- console.log(chalk.cyan.bold('│') + chalk.white.bold(' Interactive File Selector'.padEnd(76)) + chalk.cyan.bold('│'));
446
- console.log(chalk.cyan.bold('╰' + '─'.repeat(78) + '╯'));
498
+ // Helper function to display the file browser
499
+ function displayBrowser() {
500
+ // Header with keyboard controls at the top
501
+ console.log(chalk.cyan.bold('╭' + '─'.repeat(100) + '╮'));
502
+ console.log(chalk.cyan.bold('│') + chalk.white.bold(' Keyboard File Selector'.padEnd(98)) + chalk.cyan.bold('│')); console.log(chalk.cyan.bold('├' + '─'.repeat(100) + '┤'));
503
+ // Keyboard controls - compact format at top
504
+ const controls = [
505
+ '↑↓←→:Navigate', 'Space:Select', 'Enter:Open/Finish', 'Backspace:Up', 'a:All', 'c:Clear', 'Esc:Exit'
506
+ ];
507
+ const controlLine = controls.map(c => {
508
+ const [key, desc] = c.split(':');
509
+ return chalk.white(key) + chalk.gray(':') + chalk.gray(desc);
510
+ }).join(chalk.gray(' │ '));
511
+ console.log(chalk.cyan.bold('│ ') + controlLine.padEnd(98) + chalk.cyan.bold(' │'));
512
+ console.log(chalk.cyan.bold('╰' + '─'.repeat(100) + '╯'));
447
513
 
448
- // Display current path
449
- const relativePath = path.relative(currentDir, currentPath) || '.';
450
- console.log(chalk.yellow('📂 Current folder: ') + chalk.white.bold(relativePath));
514
+ console.log();
515
+ // Breadcrumb path
516
+ console.log(buildBreadcrumb());
451
517
 
452
- // Display selected files count
518
+ // Selected files count
453
519
  if (selectedFiles.length > 0) {
454
520
  const totalSize = selectedFiles.reduce((sum, file) => {
455
521
  try {
@@ -458,7 +524,9 @@ async function selectFilesInteractive(rl, currentDir = process.cwd()) {
458
524
  return sum;
459
525
  }
460
526
  }, 0);
461
- console.log(chalk.green(`✅ Selected: ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB)`));
527
+ console.log(chalk.green(`✅ Selected: ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB) - Press Enter to finish`));
528
+ } else {
529
+ console.log(chalk.gray('💡 Use Space to select files, then press Enter to finish'));
462
530
  }
463
531
 
464
532
  console.log();
@@ -468,311 +536,336 @@ async function selectFilesInteractive(rl, currentDir = process.cwd()) {
468
536
  return;
469
537
  }
470
538
 
471
- const startIdx = currentPage * itemsPerPage;
472
- const endIdx = Math.min(startIdx + itemsPerPage, fileList.length);
473
- const pageItems = fileList.slice(startIdx, endIdx);
474
-
475
- // Display page header
476
- console.log(chalk.cyan(`📋 Showing ${startIdx + 1}-${endIdx} of ${fileList.length} items (Page ${currentPage + 1}/${Math.ceil(fileList.length / itemsPerPage)})`));
477
- console.log(chalk.gray('─'.repeat(80)));
539
+ // Display files with tree-like structure
540
+ displayFileTree();
541
+ }
542
+ // Helper function to display files in a horizontal grid layout
543
+ function displayFileTree() {
544
+ const terminalWidth = process.stdout.columns || 80;
545
+ const maxDisplayItems = 50; // Show more items in grid view
546
+ const startIdx = Math.max(0, currentIndex - Math.floor(maxDisplayItems / 2));
547
+ const endIdx = Math.min(fileList.length, startIdx + maxDisplayItems);
548
+ const visibleItems = fileList.slice(startIdx, endIdx);
478
549
 
479
- // Display items
480
- pageItems.forEach((item, index) => {
481
- const globalIndex = startIdx + index + 1;
482
- const isSelected = selectedFiles.includes(item.path);
483
- const prefix = isSelected ? chalk.green('✓') : chalk.white(globalIndex.toString().padStart(2));
484
- const sizeDisplay = item.type === 'file' ? chalk.gray(`(${(item.size / 1024).toFixed(1)} KB)`) : '';
485
-
486
- if (isSelected) {
487
- console.log(`${prefix} ${chalk.green(item.name)} ${sizeDisplay}`);
488
- } else if (item.type === 'parent') {
489
- console.log(`${prefix} ${chalk.blue(item.name)}`);
490
- } else if (item.type === 'directory') {
491
- console.log(`${prefix} ${chalk.cyan(item.name)}`);
492
- } else {
493
- console.log(`${prefix} ${chalk.white(item.name)} ${sizeDisplay}`);
494
- }
495
- });
550
+ // Show scroll indicators
551
+ if (startIdx > 0) {
552
+ console.log(chalk.gray(` ⋮ (${startIdx} items above)`));
553
+ }
496
554
 
497
- console.log(chalk.gray('─'.repeat(80)));
498
- displayCommands();
499
- }
500
- // Helper function to display available commands
501
- function displayCommands() {
502
- console.log(chalk.yellow.bold('Commands:'));
503
- console.log(chalk.white(' 1-9, 10+ ') + chalk.gray('Select/deselect file by number'));
504
- console.log(chalk.white(' n/next/→ ') + chalk.gray('Next page'));
505
- console.log(chalk.white(' p/prev/← ') + chalk.gray('Previous page'));
506
- console.log(chalk.white(' a/all ') + chalk.gray('Select all files on current page'));
507
- console.log(chalk.white(' c/clear ') + chalk.gray('Clear all selections'));
508
- console.log(chalk.white(' r/remove ') + chalk.gray('Remove specific file from selection'));
509
- console.log(chalk.white(' s/search ') + chalk.gray('Search files by name or extension'));
510
- console.log(chalk.white(' f/filter ') + chalk.gray('Filter by file extension'));
511
- console.log(chalk.white(' h/help ') + chalk.gray('Show detailed help'));
512
- console.log(chalk.white(' q/quit ') + chalk.gray('Finish selection'));
513
- console.log();
514
- }
515
-
516
- // Main interaction loop
517
- while (true) {
518
- refreshFileList();
519
- displayPage();
555
+ // Calculate item width and columns
556
+ const maxItemWidth = Math.max(...visibleItems.map(item => {
557
+ const name = path.basename(item.path);
558
+ return name.length + 4; // 2 for icon + space, 2 for padding
559
+ }));
520
560
 
521
- const input = await askQuestion(rl, chalk.cyan.bold('Enter command: '));
522
- const cmd = input.trim().toLowerCase();
561
+ const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25); // Min 15, max 25 chars
562
+ const columnsPerRow = Math.floor((terminalWidth - 4) / itemWidth); // Leave 4 chars margin
563
+ const actualColumns = Math.max(1, columnsPerRow);
523
564
 
524
- if (!cmd || cmd === 'q' || cmd === 'quit') {
525
- break;
526
- }
527
- // Handle navigation
528
- if (cmd === 'n' || cmd === 'next' || cmd === '→') {
529
- if ((currentPage + 1) * itemsPerPage < fileList.length) {
530
- currentPage++;
531
- } else {
532
- console.log(chalk.yellow('Already on last page!'));
533
- await new Promise(resolve => setTimeout(resolve, 1000));
534
- }
535
- continue;
536
- }
565
+ // Group items into rows
566
+ let currentRow = '';
567
+ let itemsInCurrentRow = 0;
537
568
 
538
- if (cmd === 'p' || cmd === 'prev' || cmd === '←') {
539
- if (currentPage > 0) {
540
- currentPage--;
569
+ visibleItems.forEach((item, index) => {
570
+ const actualIndex = startIdx + index;
571
+ const isSelected = selectedFiles.includes(item.path);
572
+ const isCurrent = actualIndex === currentIndex;
573
+
574
+ // Get icon and name
575
+ let icon = '';
576
+ if (item.type === 'parent' || item.type === 'directory') {
577
+ icon = '📁';
541
578
  } else {
542
- console.log(chalk.yellow('Already on first page!'));
543
- await new Promise(resolve => setTimeout(resolve, 1000));
579
+ icon = getFileIcon(path.basename(item.path));
544
580
  }
545
- continue;
546
- }
547
-
548
- // Handle help
549
- if (cmd === 'h' || cmd === 'help') {
550
- console.clear();
551
- console.log(chalk.cyan.bold('╭' + '─'.repeat(78) + '╮'));
552
- console.log(chalk.cyan.bold('│') + chalk.white.bold(' Interactive File Selector - Help'.padEnd(76)) + chalk.cyan.bold('│'));
553
- console.log(chalk.cyan.bold('╰' + '─'.repeat(78) + '╯'));
554
- console.log();
555
- console.log(chalk.yellow.bold('📋 File Selection:'));
556
- console.log(chalk.white(' • Type a number (1-9999) to toggle file selection'));
557
- console.log(chalk.white(' • Files show with checkmarks (✓) when selected'));
558
- console.log(chalk.white(' • Directories and parent folders can be navigated into'));
559
- console.log();
560
- console.log(chalk.yellow.bold('📁 Navigation:'));
561
- console.log(chalk.white(' • Use n/next/→ to go to next page'));
562
- console.log(chalk.white(' • Use p/prev/← to go to previous page'));
563
- console.log(chalk.white(' • Click on folder numbers to enter directories'));
564
- console.log(chalk.white(' • Use .. (Parent Directory) to go up one level'));
565
- console.log();
566
- console.log(chalk.yellow.bold('🔍 Advanced Features:'));
567
- console.log(chalk.white(' • s/search: Find files by name or extension'));
568
- console.log(chalk.white(' • f/filter: Filter and select by file extension'));
569
- console.log(chalk.white(' • a/all: Select all files on current page'));
570
- console.log(chalk.white(' • c/clear: Remove all selections'));
571
- console.log(chalk.white(' • r/remove: Remove specific files from selection'));
572
- console.log();
573
- console.log(chalk.yellow.bold('💡 Tips:'));
574
- console.log(chalk.white(' • File sizes are shown in KB'));
575
- console.log(chalk.white(' • Your current selection count and size is displayed at top'));
576
- console.log(chalk.white(' • Use search for large directories to find files quickly'));
577
- console.log(chalk.white(' • Filter by extension (e.g., .pdf, .docx) for specific file types'));
578
- console.log();
579
- console.log(chalk.green('Press Enter to continue...'));
580
- await askQuestion(rl, '');
581
- continue;
582
- }
583
-
584
- // Handle selection commands
585
- if (cmd === 'a' || cmd === 'all') {
586
- const startIdx = currentPage * itemsPerPage;
587
- const endIdx = Math.min(startIdx + itemsPerPage, fileList.length);
588
- let addedCount = 0;
589
581
 
590
- for (let i = startIdx; i < endIdx; i++) {
591
- const item = fileList[i];
592
- if (item.type === 'file' && !selectedFiles.includes(item.path)) {
593
- selectedFiles.push(item.path);
594
- addedCount++;
582
+ const name = item.name || path.basename(item.path);
583
+ const truncatedName = name.length > itemWidth - 4 ? name.slice(0, itemWidth - 7) + '...' : name;
584
+
585
+ // Build item display string
586
+ let itemDisplay = `${icon} ${truncatedName}`;
587
+
588
+ // Apply styling based on state
589
+ if (isCurrent) {
590
+ if (isSelected) {
591
+ itemDisplay = chalk.black.bgGreen(` ${itemDisplay}`.padEnd(itemWidth - 1));
592
+ } else if (item.type === 'parent') {
593
+ itemDisplay = chalk.white.bgBlue(` ${itemDisplay}`.padEnd(itemWidth - 1));
594
+ } else if (item.type === 'directory') {
595
+ itemDisplay = chalk.black.bgCyan(` ${itemDisplay}`.padEnd(itemWidth - 1));
596
+ } else {
597
+ itemDisplay = chalk.black.bgWhite(` ${itemDisplay}`.padEnd(itemWidth - 1));
598
+ }
599
+ } else {
600
+ if (isSelected) {
601
+ itemDisplay = chalk.green(`✓${itemDisplay}`.padEnd(itemWidth));
602
+ } else if (item.type === 'parent') {
603
+ itemDisplay = chalk.blue(` ${itemDisplay}`.padEnd(itemWidth));
604
+ } else if (item.type === 'directory') {
605
+ itemDisplay = chalk.cyan(` ${itemDisplay}`.padEnd(itemWidth));
606
+ } else {
607
+ itemDisplay = chalk.white(` ${itemDisplay}`.padEnd(itemWidth));
595
608
  }
596
609
  }
597
610
 
598
- console.log(chalk.green(`Added ${addedCount} files to selection!`));
599
- await new Promise(resolve => setTimeout(resolve, 1000));
600
- continue;
611
+ // Add to current row
612
+ currentRow += itemDisplay;
613
+ itemsInCurrentRow++;
614
+
615
+ // Check if we need to start a new row
616
+ if (itemsInCurrentRow >= actualColumns || index === visibleItems.length - 1) {
617
+ console.log(currentRow);
618
+ currentRow = '';
619
+ itemsInCurrentRow = 0;
620
+ }
621
+ });
622
+
623
+ // Show scroll indicators
624
+ if (endIdx < fileList.length) {
625
+ console.log(chalk.gray(` ⋮ (${fileList.length - endIdx} items below)`));
601
626
  }
602
627
 
603
- if (cmd === 'c' || cmd === 'clear') {
604
- const count = selectedFiles.length;
605
- selectedFiles.length = 0;
606
- console.log(chalk.yellow(`Cleared ${count} files from selection!`));
607
- await new Promise(resolve => setTimeout(resolve, 1000));
608
- continue;
628
+ console.log();
629
+
630
+ // Show current position and navigation info
631
+ if (fileList.length > maxDisplayItems) {
632
+ console.log(chalk.gray(`Showing ${startIdx + 1}-${endIdx} of ${fileList.length} items | Current: ${currentIndex + 1}`));
633
+ } else {
634
+ console.log(chalk.gray(`${fileList.length} items | Current: ${currentIndex + 1}`));
609
635
  }
610
636
 
611
- if (cmd === 'r' || cmd === 'remove') {
612
- if (selectedFiles.length === 0) {
613
- console.log(chalk.red('No files selected to remove!'));
614
- await new Promise(resolve => setTimeout(resolve, 1000));
615
- continue;
637
+ // Show grid info
638
+ console.log(chalk.gray(`Grid: ${actualColumns} columns × ${itemWidth} chars | Terminal width: ${terminalWidth}`));
639
+ }
640
+ // Helper function to refresh file list for current directory
641
+ function refreshFileList() {
642
+ fileList = [];
643
+
644
+ try {
645
+ // Add parent directory option if not at root
646
+ if (currentPath !== currentDir) {
647
+ fileList.push({
648
+ type: 'parent',
649
+ path: path.dirname(currentPath),
650
+ name: '..'
651
+ });
616
652
  }
617
653
 
618
- console.log(chalk.cyan('\nSelected files:'));
619
- selectedFiles.forEach((file, index) => {
620
- console.log(`${index + 1}. ${path.basename(file)}`);
654
+ // Read current directory
655
+ const entries = fs.readdirSync(currentPath).sort();
656
+
657
+ // Add directories first
658
+ entries.forEach(entry => {
659
+ const fullPath = path.join(currentPath, entry);
660
+ const stat = fs.statSync(fullPath);
661
+
662
+ if (stat.isDirectory()) {
663
+ fileList.push({
664
+ type: 'directory',
665
+ path: fullPath,
666
+ name: entry
667
+ });
668
+ }
621
669
  });
622
670
 
623
- const removeInput = await askQuestion(rl, chalk.cyan('Enter number to remove (or press Enter to cancel): '));
624
- const removeIndex = parseInt(removeInput) - 1;
671
+ // Add files
672
+ entries.forEach(entry => {
673
+ const fullPath = path.join(currentPath, entry);
674
+ const stat = fs.statSync(fullPath);
675
+
676
+ if (stat.isFile()) {
677
+ fileList.push({
678
+ type: 'file',
679
+ path: fullPath,
680
+ name: entry,
681
+ size: stat.size
682
+ });
683
+ }
684
+ });
625
685
 
626
- if (removeIndex >= 0 && removeIndex < selectedFiles.length) {
627
- const removed = selectedFiles.splice(removeIndex, 1)[0];
628
- console.log(chalk.green(`Removed: ${path.basename(removed)}`));
686
+ // Ensure currentIndex is within bounds
687
+ if (currentIndex >= fileList.length) {
688
+ currentIndex = Math.max(0, fileList.length - 1);
629
689
  }
630
690
 
631
- await new Promise(resolve => setTimeout(resolve, 1000));
632
- continue;
691
+ } catch (error) {
692
+ console.error('Error reading directory:', error.message);
693
+ fileList = [];
633
694
  }
695
+ }
696
+ // Main keyboard event handler
697
+ function handleKeyInput(key) {
698
+ // Calculate grid dimensions for navigation
699
+ const terminalWidth = process.stdout.columns || 80;
700
+ const maxItemWidth = Math.max(...fileList.map(item => {
701
+ const name = path.basename(item.path);
702
+ return name.length + 4;
703
+ }));
704
+ const itemWidth = Math.min(Math.max(maxItemWidth, 15), 25);
705
+ const columnsPerRow = Math.max(1, Math.floor((terminalWidth - 4) / itemWidth));
634
706
 
635
- if (cmd === 's' || cmd === 'search') {
636
- const searchTerm = await askQuestion(rl, chalk.cyan('Enter search term (filename or extension): '));
637
- if (searchTerm) {
638
- const matches = fileList.filter(item =>
639
- item.type === 'file' &&
640
- (item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
641
- path.extname(item.path).toLowerCase() === searchTerm.toLowerCase())
642
- );
707
+ switch (key) {
708
+ case KEYS.UP:
709
+ // Move up by one row (subtract columns)
710
+ const newUpIndex = currentIndex - columnsPerRow;
711
+ if (newUpIndex >= 0) {
712
+ currentIndex = newUpIndex;
713
+ displayBrowser();
714
+ }
715
+ break;
643
716
 
644
- if (matches.length > 0) {
645
- console.log(chalk.cyan(`\nFound ${matches.length} matches:`));
646
- matches.forEach((item, index) => {
647
- const isSelected = selectedFiles.includes(item.path);
648
- const prefix = isSelected ? chalk.green('✓') : chalk.white((index + 1).toString());
649
- console.log(`${prefix} ${item.name} ${chalk.gray(`(${(item.size / 1024).toFixed(1)} KB)`)}`);
650
- });
651
-
652
- const selectAll = await askConfirmation(rl, chalk.cyan('Select all search results?'), false);
653
- if (selectAll) {
654
- matches.forEach(item => {
655
- if (!selectedFiles.includes(item.path)) {
656
- selectedFiles.push(item.path);
657
- }
658
- });
659
- console.log(chalk.green(`Added ${matches.length} files!`));
717
+ case KEYS.DOWN:
718
+ // Move down by one row (add columns)
719
+ const newDownIndex = currentIndex + columnsPerRow;
720
+ if (newDownIndex < fileList.length) {
721
+ currentIndex = newDownIndex;
722
+ displayBrowser();
723
+ }
724
+ break;
725
+
726
+ case KEYS.LEFT:
727
+ // Move left by one column
728
+ if (currentIndex > 0) {
729
+ currentIndex--;
730
+ displayBrowser();
731
+ }
732
+ break;
733
+
734
+ case KEYS.RIGHT:
735
+ // Move right by one column
736
+ if (currentIndex < fileList.length - 1) {
737
+ currentIndex++;
738
+ displayBrowser();
739
+ }
740
+ break;
741
+
742
+ case KEYS.SPACE:
743
+ if (fileList.length > 0) {
744
+ const item = fileList[currentIndex];
745
+ if (item.type === 'file') {
746
+ const index = selectedFiles.indexOf(item.path);
747
+ if (index === -1) {
748
+ selectedFiles.push(item.path);
749
+ } else {
750
+ selectedFiles.splice(index, 1);
751
+ }
752
+ displayBrowser();
753
+ }
754
+ }
755
+ break;
756
+ case KEYS.ENTER:
757
+ if (fileList.length > 0) {
758
+ const item = fileList[currentIndex];
759
+ if (item.type === 'parent' || item.type === 'directory') {
760
+ currentPath = item.path;
761
+ currentIndex = 0;
762
+ refreshFileList();
763
+ displayBrowser();
764
+ } else {
765
+ // When Enter is pressed on a file, finish selection if files are selected
766
+ if (selectedFiles.length > 0) {
767
+ isNavigating = false;
768
+ }
660
769
  }
661
770
  } else {
662
- console.log(chalk.yellow('No matches found.'));
771
+ // Finish selection when directory is empty and Enter is pressed (if files selected)
772
+ if (selectedFiles.length > 0) {
773
+ isNavigating = false;
774
+ }
663
775
  }
776
+ break;
664
777
 
665
- await new Promise(resolve => setTimeout(resolve, 2000));
666
- }
667
- continue;
778
+ case KEYS.BACKSPACE:
779
+ if (currentPath !== currentDir) {
780
+ currentPath = path.dirname(currentPath);
781
+ currentIndex = 0;
782
+ refreshFileList();
783
+ displayBrowser();
784
+ }
785
+ break;
786
+
787
+ case 'a':
788
+ // Select all files in current directory
789
+ let addedCount = 0;
790
+ fileList.forEach(item => {
791
+ if (item.type === 'file' && !selectedFiles.includes(item.path)) {
792
+ selectedFiles.push(item.path);
793
+ addedCount++;
794
+ }
795
+ });
796
+ if (addedCount > 0) {
797
+ displayBrowser();
798
+ }
799
+ break;
800
+
801
+ case 'c':
802
+ // Clear all selections
803
+ selectedFiles.length = 0;
804
+ displayBrowser();
805
+ break;
806
+
807
+ case KEYS.ESCAPE:
808
+ case '\u001b': // ESC key
809
+ isNavigating = false;
810
+ break;
811
+
812
+ default:
813
+ // Ignore other keys
814
+ break;
668
815
  }
816
+ }
817
+
818
+ // Initialize and start navigation
819
+ return new Promise((resolve) => {
820
+ refreshFileList();
821
+ displayBrowser();
669
822
 
670
- if (cmd === 'f' || cmd === 'filter') {
671
- const allExtensions = [...new Set(
672
- fileList
673
- .filter(item => item.type === 'file')
674
- .map(item => path.extname(item.path).toLowerCase())
675
- .filter(ext => ext)
676
- )].sort();
677
-
678
- if (allExtensions.length === 0) {
679
- console.log(chalk.yellow('No file extensions found!'));
680
- await new Promise(resolve => setTimeout(resolve, 1000));
681
- continue;
823
+ process.stdin.on('data', (key) => {
824
+ if (!isNavigating) {
825
+ return;
682
826
  }
683
827
 
684
- console.log(chalk.cyan('\nAvailable extensions:'));
685
- allExtensions.forEach((ext, index) => {
686
- const count = fileList.filter(item =>
687
- item.type === 'file' && path.extname(item.path).toLowerCase() === ext
688
- ).length;
689
- console.log(`${index + 1}. ${ext} (${count} files)`);
690
- });
691
-
692
- const extChoice = await askQuestion(rl, chalk.cyan('Enter extension number (or press Enter to cancel): '));
693
- const extIndex = parseInt(extChoice) - 1;
828
+ const keyStr = key.toString();
829
+ handleKeyInput(keyStr);
694
830
 
695
- if (extIndex >= 0 && extIndex < allExtensions.length) {
696
- const selectedExt = allExtensions[extIndex];
697
- const matches = fileList.filter(item =>
698
- item.type === 'file' && path.extname(item.path).toLowerCase() === selectedExt
699
- );
831
+ if (!isNavigating) {
832
+ // Cleanup
833
+ process.stdin.removeAllListeners('data');
834
+ process.stdin.setRawMode(false);
835
+ process.stdin.pause();
700
836
 
701
- const selectAll = await askConfirmation(rl, chalk.cyan(`Select all ${selectedExt} files?`), false);
702
- if (selectAll) {
703
- matches.forEach(item => {
704
- if (!selectedFiles.includes(item.path)) {
705
- selectedFiles.push(item.path);
837
+ // Display completion summary
838
+ if (selectedFiles.length > 0) {
839
+ console.log(chalk.green.bold('✅ File Selection Complete!'));
840
+ console.log(chalk.cyan('-'.repeat(50)));
841
+
842
+ const totalSize = selectedFiles.reduce((sum, file) => {
843
+ try {
844
+ return sum + fs.statSync(file).size;
845
+ } catch {
846
+ return sum;
847
+ }
848
+ }, 0);
849
+
850
+ console.log(chalk.white(`Selected ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB total):`));
851
+ selectedFiles.forEach((file, index) => {
852
+ try {
853
+ const stats = fs.statSync(file);
854
+ const size = (stats.size / 1024).toFixed(1) + ' KB';
855
+ console.log(pad(chalk.green(`${index + 1}.`), 5) + pad(path.basename(file), 35) + chalk.gray(size));
856
+ } catch (e) {
857
+ console.log(pad(chalk.red(`${index + 1}.`), 5) + chalk.red(path.basename(file) + ' (Error reading file)'));
706
858
  }
707
859
  });
708
- console.log(chalk.green(`Added ${matches.length} ${selectedExt} files!`));
709
- }
710
- }
711
-
712
- await new Promise(resolve => setTimeout(resolve, 1000));
713
- continue;
714
- }
715
-
716
- // Handle numeric selection
717
- const num = parseInt(cmd);
718
- if (!isNaN(num) && num > 0 && num <= fileList.length) {
719
- const item = fileList[num - 1];
720
-
721
- if (item.type === 'parent') {
722
- currentPath = item.path;
723
- currentPage = 0;
724
- continue;
725
- }
726
-
727
- if (item.type === 'directory') {
728
- currentPath = item.path;
729
- currentPage = 0;
730
- continue;
731
- }
732
- if (item.type === 'file') {
733
- const index = selectedFiles.indexOf(item.path);
734
- if (index === -1) {
735
- selectedFiles.push(item.path);
736
- const size = (item.size / 1024).toFixed(1);
737
- console.log(chalk.green(`✓ Added: ${path.basename(item.path)} (${size} KB)`));
860
+ console.log(chalk.cyan('-'.repeat(50)));
738
861
  } else {
739
- selectedFiles.splice(index, 1);
740
- console.log(chalk.yellow(`✗ Removed: ${path.basename(item.path)}`));
862
+ console.log(chalk.yellow('No files selected.'));
741
863
  }
742
- await new Promise(resolve => setTimeout(resolve, 800));
743
- continue;
744
- }
745
- }
746
- console.log(chalk.red('Invalid command!'));
747
- await new Promise(resolve => setTimeout(resolve, 1000));
748
- }
749
-
750
- // Display completion summary
751
- if (selectedFiles.length > 0) {
752
- console.clear();
753
- console.log(chalk.green.bold('✅ File Selection Complete!'));
754
- console.log(chalk.cyan('-'.repeat(50)));
755
-
756
- const totalSize = selectedFiles.reduce((sum, file) => {
757
- try {
758
- return sum + fs.statSync(file).size;
759
- } catch {
760
- return sum;
864
+
865
+ resolve(selectedFiles);
761
866
  }
762
- }, 0);
763
-
764
- console.log(chalk.white(`Selected ${selectedFiles.length} files (${(totalSize / 1024).toFixed(1)} KB total):`));
765
- selectedFiles.forEach((file, index) => {
766
- const stats = fs.statSync(file);
767
- const size = (stats.size / 1024).toFixed(1) + ' KB';
768
- console.log(pad(chalk.green(`${index + 1}.`), 5) + pad(path.basename(file), 35) + chalk.gray(size));
769
867
  });
770
- console.log(chalk.cyan('-'.repeat(50)));
771
- } else {
772
- console.log(chalk.yellow('No files selected.'));
773
- }
774
-
775
- return selectedFiles;
868
+ });
776
869
  }
777
870
 
778
871
  export {
@@ -782,7 +875,7 @@ export {
782
875
  askConfirmation,
783
876
  selectFromList,
784
877
  selectFilesImproved,
785
- selectFilesInteractive,
878
+ selectFilesKeyboard,
786
879
  getFilesMatchingWildcard,
787
880
  getSubfoldersRecursive,
788
881
  pad
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvaslms-cli",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "A command line tool for interacting with Canvas LMS API",
5
5
  "keywords": [
6
6
  "canvas",
@@ -38,8 +38,8 @@
38
38
  "CHANGELOG.md"
39
39
  ],
40
40
  "scripts": {
41
- "start": "node src/index.js",
42
- "dev": "node src/index.js",
41
+ "start": "bun src/index.js",
42
+ "dev": "bun src/index.js",
43
43
  "test": "echo \"Error: no test specified\" && exit 1",
44
44
  "lint": "echo \"Add linting here\"",
45
45
  "format": "echo \"Add formatting here\"",