canvaslms-cli 1.4.1 → 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 +1 -1
- package/README.md +60 -134
- package/commands/submit.js +25 -7
- package/lib/file-upload.js +2 -1
- package/lib/interactive.js +515 -6
- package/package.json +3 -3
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,182 +1,106 @@
|
|
|
1
1
|
# Canvas CLI Tool
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
|
20
|
+
### Global (Recommended)
|
|
17
21
|
|
|
18
22
|
```bash
|
|
19
23
|
npm install -g canvas-cli-tool
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
### Local
|
|
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
|
|
31
|
-
- Log
|
|
36
|
+
1. **Get your Canvas API Token**
|
|
37
|
+
- Log in to Canvas
|
|
32
38
|
- Go to Account → Settings
|
|
33
|
-
-
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
61
|
+
### Common Commands
|
|
105
62
|
|
|
106
63
|
```bash
|
|
107
|
-
#
|
|
108
|
-
canvas
|
|
109
|
-
|
|
110
|
-
#
|
|
111
|
-
canvas grades
|
|
112
|
-
|
|
113
|
-
# Show
|
|
114
|
-
canvas
|
|
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
|
-
###
|
|
74
|
+
### Assignment Submission
|
|
121
75
|
|
|
122
76
|
```bash
|
|
123
|
-
#
|
|
124
|
-
canvas
|
|
125
|
-
|
|
126
|
-
#
|
|
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
|
|
139
|
-
|
|
140
|
-
| `list`
|
|
141
|
-
| `assignments`
|
|
142
|
-
| `submit`
|
|
143
|
-
| `grades`
|
|
144
|
-
| `announcements` | `announce
|
|
145
|
-
| `profile`
|
|
146
|
-
| `config`
|
|
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.
|
|
178
|
-
- npm >= 6.
|
|
179
|
-
- Valid Canvas LMS
|
|
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
|
|
188
|
-
6. Push
|
|
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)
|
|
119
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
package/commands/submit.js
CHANGED
|
@@ -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, 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,9 +137,8 @@ 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
|
-
|
|
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();
|
|
@@ -147,14 +146,33 @@ export async function submitAssignment(options) {
|
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
|
-
|
|
149
|
+
|
|
150
|
+
// Step 3: Choose file selection method and select files
|
|
151
151
|
let filePaths = [];
|
|
152
152
|
console.log(chalk.cyan.bold('-'.repeat(60)));
|
|
153
|
-
console.log(chalk.cyan.bold('File Selection'));
|
|
153
|
+
console.log(chalk.cyan.bold('File Selection Method'));
|
|
154
154
|
console.log(chalk.cyan('-'.repeat(60)));
|
|
155
155
|
console.log(chalk.white('Course: ') + chalk.bold(selectedCourse.name));
|
|
156
156
|
console.log(chalk.white('Assignment: ') + chalk.bold(selectedAssignment.name) + '\n');
|
|
157
|
-
|
|
157
|
+
|
|
158
|
+
console.log(chalk.yellow('📁 Choose file selection method:'));
|
|
159
|
+
console.log(chalk.white('1. ') + chalk.cyan('Keyboard Navigator') + chalk.gray(' (NEW! - Use arrow keys and space bar to navigate and select)'));
|
|
160
|
+
console.log(chalk.white('2. ') + chalk.cyan('Text-based Selector') + chalk.gray(' (Traditional - Type filenames and wildcards)'));
|
|
161
|
+
console.log(chalk.white('3. ') + chalk.cyan('Basic Directory Listing') + chalk.gray(' (Simple - Select from numbered list)'));
|
|
162
|
+
|
|
163
|
+
const selectorChoice = await askQuestion(rl, chalk.bold.cyan('\nChoose method (1-3): '));
|
|
164
|
+
|
|
165
|
+
console.log(chalk.cyan.bold('-'.repeat(60)));
|
|
166
|
+
console.log(chalk.cyan.bold('File Selection'));
|
|
167
|
+
console.log(chalk.cyan('-'.repeat(60)));
|
|
168
|
+
|
|
169
|
+
if (selectorChoice === '1') {
|
|
170
|
+
filePaths = await selectFilesKeyboard(rl);
|
|
171
|
+
} else if (selectorChoice === '2') {
|
|
172
|
+
filePaths = await selectFilesImproved(rl);
|
|
173
|
+
} else {
|
|
174
|
+
filePaths = await selectFiles(rl);
|
|
175
|
+
}
|
|
158
176
|
// Validate all selected files exist
|
|
159
177
|
const validFiles = [];
|
|
160
178
|
for (const file of filePaths) {
|
package/lib/file-upload.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
6
7
|
import axios from 'axios';
|
|
7
8
|
import FormData from 'form-data';
|
|
8
9
|
import { makeCanvasRequest } from './api-client.js';
|
|
@@ -17,7 +18,7 @@ export async function uploadSingleFileToCanvas(courseId, assignmentId, filePath)
|
|
|
17
18
|
throw new Error(`File not found: ${filePath}`);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const fileName =
|
|
21
|
+
const fileName = path.basename(filePath);
|
|
21
22
|
const fileContent = fs.readFileSync(filePath);
|
|
22
23
|
|
|
23
24
|
// Step 1: Get upload URL from Canvas
|
package/lib/interactive.js
CHANGED
|
@@ -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 ?
|
|
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
|
-
|
|
72
|
+
// If user presses Enter → return defaultYes (true by default)
|
|
73
|
+
if (lower === "") {
|
|
58
74
|
return defaultYes;
|
|
59
75
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
/**
|
|
@@ -359,6 +383,490 @@ async function selectFilesImproved(rl, currentDir = process.cwd()) {
|
|
|
359
383
|
return selectedFiles;
|
|
360
384
|
}
|
|
361
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Interactive file selector with tree view and keyboard navigation
|
|
388
|
+
*/
|
|
389
|
+
async function selectFilesKeyboard(rl, currentDir = process.cwd()) {
|
|
390
|
+
const selectedFiles = [];
|
|
391
|
+
let expandedFolders = new Set();
|
|
392
|
+
let fileTree = [];
|
|
393
|
+
let fileList = [];
|
|
394
|
+
let currentPath = currentDir;
|
|
395
|
+
let currentIndex = 0;
|
|
396
|
+
let isNavigating = true;
|
|
397
|
+
let viewStartIndex = 0;
|
|
398
|
+
const maxVisibleItems = 15;
|
|
399
|
+
|
|
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 = [];
|
|
408
|
+
try {
|
|
409
|
+
const entries = fs.readdirSync(basePath).sort();
|
|
410
|
+
|
|
411
|
+
// Add directories first
|
|
412
|
+
entries.forEach(entry => {
|
|
413
|
+
const fullPath = path.join(basePath, entry);
|
|
414
|
+
const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const stats = fs.statSync(fullPath);
|
|
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,
|
|
422
|
+
path: fullPath,
|
|
423
|
+
relativePath,
|
|
424
|
+
type: 'directory',
|
|
425
|
+
level,
|
|
426
|
+
isExpanded,
|
|
427
|
+
size: 0
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// If expanded, add children
|
|
431
|
+
if (isExpanded) {
|
|
432
|
+
const children = buildFileTree(fullPath, level + 1, relativePath);
|
|
433
|
+
tree.push(...children);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} catch (e) {}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Add files
|
|
440
|
+
entries.forEach(entry => {
|
|
441
|
+
const fullPath = path.join(basePath, entry);
|
|
442
|
+
const relativePath = parentPath ? `${parentPath}/${entry}` : entry;
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const stats = fs.statSync(fullPath);
|
|
446
|
+
if (stats.isFile()) {
|
|
447
|
+
tree.push({
|
|
448
|
+
name: entry,
|
|
449
|
+
path: fullPath,
|
|
450
|
+
relativePath,
|
|
451
|
+
type: 'file',
|
|
452
|
+
level,
|
|
453
|
+
size: stats.size
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
} catch (e) {}
|
|
457
|
+
});
|
|
458
|
+
} catch (error) {
|
|
459
|
+
console.log(chalk.red('Error reading directory: ' + error.message));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return tree;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Helper function to get file icon based on extension
|
|
466
|
+
function getFileIcon(filename) {
|
|
467
|
+
const ext = path.extname(filename).toLowerCase();
|
|
468
|
+
const icons = {
|
|
469
|
+
'.pdf': '📄', '.doc': '📄', '.docx': '📄', '.txt': '📄',
|
|
470
|
+
'.js': '📜', '.ts': '📜', '.py': '📜', '.java': '📜', '.cpp': '📜', '.c': '📜',
|
|
471
|
+
'.html': '🌐', '.css': '🎨', '.scss': '🎨', '.less': '🎨',
|
|
472
|
+
'.json': '⚙️', '.xml': '⚙️', '.yml': '⚙️', '.yaml': '⚙️',
|
|
473
|
+
'.zip': '📦', '.rar': '📦', '.7z': '📦', '.tar': '📦',
|
|
474
|
+
'.jpg': '🖼️', '.jpeg': '🖼️', '.png': '🖼️', '.gif': '🖼️', '.svg': '🖼️',
|
|
475
|
+
'.mp4': '🎬', '.avi': '🎬', '.mov': '🎬', '.mkv': '🎬',
|
|
476
|
+
'.mp3': '🎵', '.wav': '🎵', '.flac': '🎵'
|
|
477
|
+
};
|
|
478
|
+
return icons[ext] || '📋';
|
|
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
|
+
}
|
|
497
|
+
|
|
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) + '╯'));
|
|
513
|
+
|
|
514
|
+
console.log();
|
|
515
|
+
// Breadcrumb path
|
|
516
|
+
console.log(buildBreadcrumb());
|
|
517
|
+
|
|
518
|
+
// Selected files count
|
|
519
|
+
if (selectedFiles.length > 0) {
|
|
520
|
+
const totalSize = selectedFiles.reduce((sum, file) => {
|
|
521
|
+
try {
|
|
522
|
+
return sum + fs.statSync(file).size;
|
|
523
|
+
} catch {
|
|
524
|
+
return sum;
|
|
525
|
+
}
|
|
526
|
+
}, 0);
|
|
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'));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
console.log();
|
|
533
|
+
|
|
534
|
+
if (fileList.length === 0) {
|
|
535
|
+
console.log(chalk.yellow('📭 No files found in this directory.'));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
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);
|
|
549
|
+
|
|
550
|
+
// Show scroll indicators
|
|
551
|
+
if (startIdx > 0) {
|
|
552
|
+
console.log(chalk.gray(` ⋮ (${startIdx} items above)`));
|
|
553
|
+
}
|
|
554
|
+
|
|
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
|
+
}));
|
|
560
|
+
|
|
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);
|
|
564
|
+
|
|
565
|
+
// Group items into rows
|
|
566
|
+
let currentRow = '';
|
|
567
|
+
let itemsInCurrentRow = 0;
|
|
568
|
+
|
|
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 = '📁';
|
|
578
|
+
} else {
|
|
579
|
+
icon = getFileIcon(path.basename(item.path));
|
|
580
|
+
}
|
|
581
|
+
|
|
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));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
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)`));
|
|
626
|
+
}
|
|
627
|
+
|
|
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}`));
|
|
635
|
+
}
|
|
636
|
+
|
|
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
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
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
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
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
|
+
});
|
|
685
|
+
|
|
686
|
+
// Ensure currentIndex is within bounds
|
|
687
|
+
if (currentIndex >= fileList.length) {
|
|
688
|
+
currentIndex = Math.max(0, fileList.length - 1);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error('Error reading directory:', error.message);
|
|
693
|
+
fileList = [];
|
|
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));
|
|
706
|
+
|
|
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;
|
|
716
|
+
|
|
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
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} else {
|
|
771
|
+
// Finish selection when directory is empty and Enter is pressed (if files selected)
|
|
772
|
+
if (selectedFiles.length > 0) {
|
|
773
|
+
isNavigating = false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
break;
|
|
777
|
+
|
|
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;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Initialize and start navigation
|
|
819
|
+
return new Promise((resolve) => {
|
|
820
|
+
refreshFileList();
|
|
821
|
+
displayBrowser();
|
|
822
|
+
|
|
823
|
+
process.stdin.on('data', (key) => {
|
|
824
|
+
if (!isNavigating) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const keyStr = key.toString();
|
|
829
|
+
handleKeyInput(keyStr);
|
|
830
|
+
|
|
831
|
+
if (!isNavigating) {
|
|
832
|
+
// Cleanup
|
|
833
|
+
process.stdin.removeAllListeners('data');
|
|
834
|
+
process.stdin.setRawMode(false);
|
|
835
|
+
process.stdin.pause();
|
|
836
|
+
|
|
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)'));
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
console.log(chalk.cyan('-'.repeat(50)));
|
|
861
|
+
} else {
|
|
862
|
+
console.log(chalk.yellow('No files selected.'));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
resolve(selectedFiles);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
}
|
|
362
870
|
|
|
363
871
|
export {
|
|
364
872
|
createReadlineInterface,
|
|
@@ -367,6 +875,7 @@ export {
|
|
|
367
875
|
askConfirmation,
|
|
368
876
|
selectFromList,
|
|
369
877
|
selectFilesImproved,
|
|
878
|
+
selectFilesKeyboard,
|
|
370
879
|
getFilesMatchingWildcard,
|
|
371
880
|
getSubfoldersRecursive,
|
|
372
881
|
pad
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvaslms-cli",
|
|
3
|
-
"version": "1.4.
|
|
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": "
|
|
42
|
-
"dev": "
|
|
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\"",
|