@slugbugblue/trax-cli 0.12.0 → 0.13.0
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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/eslint.config.js +24 -0
- package/package.json +7 -18
- package/src/cli.js +246 -106
- package/src/cmds/analyze.js +72 -66
- package/src/cmds/delete.js +35 -43
- package/src/cmds/help.js +54 -57
- package/src/cmds/import-export.js +149 -122
- package/src/cmds/index.js +40 -0
- package/src/cmds/list.js +92 -87
- package/src/cmds/new.js +50 -55
- package/src/cmds/notes.js +20 -18
- package/src/cmds/play-try.js +147 -106
- package/src/cmds/puzzles.js +53 -39
- package/src/cmds/select.js +22 -31
- package/src/cmds/suggest.js +28 -37
- package/src/cmds/undo.js +31 -26
- package/src/cmds/view.js +42 -51
- package/src/utils.js +1 -1
- package/src/version.js +1 -1
- package/xo.config.js +10 -0
- package/src/types.d.ts +0 -33
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* You may obtain a copy of the License at
|
|
6
|
-
*
|
|
7
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
*
|
|
9
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
* See the License for the specific language governing permissions and
|
|
13
|
-
* limitations under the License.
|
|
1
|
+
/** CLI import/export commands
|
|
2
|
+
* @copyright 2022-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
14
5
|
*/
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('../cli.js').CLIContext} CLIContext
|
|
9
|
+
* @typedef {import('../cli.js').TraxVariant} TraxVariant
|
|
10
|
+
* @typedef {import('../cli.js').GameNotes} GameNotes
|
|
11
|
+
*/
|
|
17
12
|
|
|
18
13
|
import fs from 'node:fs/promises'
|
|
19
14
|
import process from 'node:process'
|
|
@@ -22,14 +17,17 @@ import { Trax } from '@slugbugblue/trax'
|
|
|
22
17
|
import { puzzles, sources } from '@slugbugblue/trax-puzzles'
|
|
23
18
|
|
|
24
19
|
const paths = envPaths('trax', { suffix: '' })
|
|
20
|
+
/** @type {Record<string, string>} */
|
|
25
21
|
const PATHS = {}
|
|
26
22
|
|
|
27
23
|
// Store a copy of the CLI context object locally
|
|
24
|
+
/** @type {Partial<CLIContext>} */
|
|
28
25
|
let cli = {}
|
|
29
26
|
|
|
27
|
+
/** @param {unknown} fsError */
|
|
30
28
|
const fsOuch = (fsError) => {
|
|
31
29
|
if (cli.out) {
|
|
32
|
-
cli.out(fsError
|
|
30
|
+
cli.out(String(fsError))
|
|
33
31
|
} else {
|
|
34
32
|
console.log(fsError)
|
|
35
33
|
}
|
|
@@ -37,18 +35,24 @@ const fsOuch = (fsError) => {
|
|
|
37
35
|
throw fsError
|
|
38
36
|
}
|
|
39
37
|
|
|
38
|
+
/** @param {string} name @returns {string} */
|
|
40
39
|
const expandPath = (name) => {
|
|
41
40
|
// Expand home directory ... anything else?
|
|
42
|
-
if (name.includes('~')) name = name.replace('~', process.env.HOME)
|
|
41
|
+
if (name.includes('~')) name = name.replace('~', process.env.HOME ?? '~')
|
|
43
42
|
return name
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
// Return the actual path on disk, creating it if necessary.
|
|
47
46
|
// Path is one of cache, config, data, log, temp.
|
|
47
|
+
/** @param {string} path @returns {Promise<string | false>} */
|
|
48
48
|
const getPath = async (path) => {
|
|
49
49
|
if (path in PATHS) return PATHS[path]
|
|
50
50
|
if (path in paths) {
|
|
51
|
-
const
|
|
51
|
+
const pathsRecord = /** @type {Record<string, string>} */ (
|
|
52
|
+
/** @type {unknown} */ (paths)
|
|
53
|
+
)
|
|
54
|
+
const realPath = pathsRecord[path]
|
|
55
|
+
await fs.mkdir(realPath, { recursive: true })
|
|
52
56
|
PATHS[path] = realPath
|
|
53
57
|
return realPath
|
|
54
58
|
}
|
|
@@ -56,6 +60,7 @@ const getPath = async (path) => {
|
|
|
56
60
|
return false
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/** @returns {string} */
|
|
59
64
|
const hexy = (length = 6) => {
|
|
60
65
|
let hex = ''
|
|
61
66
|
while (hex.length < length) {
|
|
@@ -65,6 +70,13 @@ const hexy = (length = 6) => {
|
|
|
65
70
|
return hex
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} filetype
|
|
75
|
+
* @param {string} filename
|
|
76
|
+
* @param {Record<string, unknown>} variable
|
|
77
|
+
* @param {(s: string) => Record<string, unknown>} decode
|
|
78
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
79
|
+
*/
|
|
68
80
|
export const getFile = async (filetype, filename, variable, decode) => {
|
|
69
81
|
const path = await getPath(filetype)
|
|
70
82
|
if (path) {
|
|
@@ -81,7 +93,9 @@ export const getFile = async (filetype, filename, variable, decode) => {
|
|
|
81
93
|
} else {
|
|
82
94
|
console.log(error)
|
|
83
95
|
}
|
|
84
|
-
} else if (
|
|
96
|
+
} else if (
|
|
97
|
+
/** @type {NodeJS.ErrnoException} */ (fsError).code !== 'ENOENT'
|
|
98
|
+
) {
|
|
85
99
|
fsOuch(fsError)
|
|
86
100
|
}
|
|
87
101
|
}
|
|
@@ -90,6 +104,12 @@ export const getFile = async (filetype, filename, variable, decode) => {
|
|
|
90
104
|
return variable
|
|
91
105
|
}
|
|
92
106
|
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} filetype
|
|
109
|
+
* @param {string} filename
|
|
110
|
+
* @param {string} data
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
93
113
|
export const saveFile = async (filetype, filename, data) => {
|
|
94
114
|
const path = await getPath(filetype)
|
|
95
115
|
if (path) {
|
|
@@ -117,6 +137,36 @@ export const importCmd = {
|
|
|
117
137
|
'Load a ".trx" game file into memory. Once imported, it will be assigned',
|
|
118
138
|
'a game id and automatically selected for future commands.',
|
|
119
139
|
],
|
|
140
|
+
/** @param {CLIContext} CLI @param {string} [filename] */
|
|
141
|
+
async fn(CLI, filename) {
|
|
142
|
+
cli = CLI
|
|
143
|
+
if (!filename) {
|
|
144
|
+
CLI.error('Missing filename.')
|
|
145
|
+
return CLI.do('help', 'import')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let content
|
|
149
|
+
try {
|
|
150
|
+
content = await fs.readFile(expandPath(filename), 'utf8')
|
|
151
|
+
} catch (fsError) {
|
|
152
|
+
if (
|
|
153
|
+
/** @type {NodeJS.ErrnoException} */ (fsError).code === 'ENOENT' &&
|
|
154
|
+
!filename.endsWith('.trx')
|
|
155
|
+
) {
|
|
156
|
+
try {
|
|
157
|
+
content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
|
|
158
|
+
} catch {
|
|
159
|
+
content = null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!content) return CLI.error(String(fsError))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!interpretFile(CLI, content)) {
|
|
167
|
+
CLI.error(filename + ' does not appear to be formatted correctly.')
|
|
168
|
+
}
|
|
169
|
+
},
|
|
120
170
|
}
|
|
121
171
|
|
|
122
172
|
export const exportCmd = {
|
|
@@ -129,29 +179,95 @@ export const exportCmd = {
|
|
|
129
179
|
'Export the current game to an external ".trx" file for safe-keeping or',
|
|
130
180
|
'to share. Specify a game id to export a different game.',
|
|
131
181
|
],
|
|
182
|
+
/** @param {CLIContext} CLI @param {string} [id] @param {string} [filename] */
|
|
183
|
+
async fn(CLI, id, filename) {
|
|
184
|
+
cli = CLI
|
|
185
|
+
let game = CLI.GAME
|
|
186
|
+
if (id) {
|
|
187
|
+
const newid = id.replace(/^#/v, '')
|
|
188
|
+
game = CLI.GAMES[newid]
|
|
189
|
+
if (!game) {
|
|
190
|
+
filename = id
|
|
191
|
+
game = CLI.GAME
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!game || !game.id) {
|
|
196
|
+
return CLI.error('No active game. Type "new" to start a game.')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const trax = new Trax(game.rules, game.moves, 'cli')
|
|
200
|
+
|
|
201
|
+
let content = Trax.names[game.rules].replace(' ', '') + '\n'
|
|
202
|
+
content += game.players?.[0] || 'white'
|
|
203
|
+
content += ' vs '
|
|
204
|
+
content += game.players?.[1] || 'black'
|
|
205
|
+
content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
|
|
206
|
+
if (game.puzzle) {
|
|
207
|
+
const puzzle = puzzles.find((p) => p.id === game.puzzle)
|
|
208
|
+
if (puzzle) {
|
|
209
|
+
const source = sources[puzzle.src]
|
|
210
|
+
content += '; Puzzle ' + game.puzzle
|
|
211
|
+
if (source) {
|
|
212
|
+
content += source.copyright
|
|
213
|
+
? ' ©' + source.copyright + ' by '
|
|
214
|
+
: ' provided courtesy of '
|
|
215
|
+
content += source.name
|
|
216
|
+
if (source.url) content += '\n; ' + source.url
|
|
217
|
+
if (source.license) {
|
|
218
|
+
content += '\n; Licensed under ' + source.license
|
|
219
|
+
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
content += '\n'
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
content += interleaveNotation(trax.moves, game.notes)
|
|
228
|
+
|
|
229
|
+
filename &&= filename.replaceAll(/[^ a-z\d.~\/]/gv, '')
|
|
230
|
+
if (filename) {
|
|
231
|
+
const fname = filename.split('/').pop()
|
|
232
|
+
if (!fname) filename += game.rules + game.id
|
|
233
|
+
if (!fname || !fname.includes('.')) filename += '.trx'
|
|
234
|
+
CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFile(expandPath(filename), content)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
fsOuch(error)
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
CLI.out(content)
|
|
242
|
+
}
|
|
243
|
+
},
|
|
132
244
|
}
|
|
133
245
|
|
|
134
246
|
/** Interpret the rules string of a .trx file.
|
|
135
|
-
* @
|
|
247
|
+
* @param {string} r - rules string in lower case
|
|
136
248
|
* @returns {TraxVariant}
|
|
137
249
|
*/
|
|
138
250
|
const getRules = (r) =>
|
|
139
251
|
r.includes('loop') ? 'traxloop' : r.includes('8x8') ? 'trax8' : 'trax'
|
|
140
252
|
|
|
141
253
|
/** Decode a .trx formatted string into a game, preserving players and comments.
|
|
142
|
-
* @
|
|
143
|
-
* @
|
|
254
|
+
* @param {CLIContext} CLI - the CLI object
|
|
255
|
+
* @param {string} content - the contents of the .trx file
|
|
144
256
|
* @returns {boolean} - true if a game was created
|
|
145
257
|
*/
|
|
146
258
|
const interpretFile = (CLI, content) => {
|
|
259
|
+
/** @type {TraxVariant | undefined} */
|
|
147
260
|
let rules
|
|
148
261
|
let trax
|
|
262
|
+
/** @type {string[]} */
|
|
149
263
|
let players = []
|
|
264
|
+
/** @type {string[]} */
|
|
150
265
|
const moves = []
|
|
266
|
+
/** @type {GameNotes} */
|
|
151
267
|
const comments = []
|
|
152
268
|
for (const line of content.split('\n')) {
|
|
153
269
|
if (!line) continue
|
|
154
|
-
const [ln, ...comment] = line.split(/[#;]/)
|
|
270
|
+
const [ln, ...comment] = line.split(/[#;]/v)
|
|
155
271
|
if (ln) {
|
|
156
272
|
const lower = ln.toLowerCase()
|
|
157
273
|
if (!rules && lower.includes('trax')) {
|
|
@@ -182,7 +298,7 @@ const interpretFile = (CLI, content) => {
|
|
|
182
298
|
|
|
183
299
|
if (comment.length > 0) {
|
|
184
300
|
const note = comment.map((c) => c.trim()).join(' ')
|
|
185
|
-
if (!/^@slugbugblue\/trax cli\.js v
|
|
301
|
+
if (!/^@slugbugblue\/trax cli\.js v/v.test(note)) {
|
|
186
302
|
comments.push({
|
|
187
303
|
note,
|
|
188
304
|
move: trax?.move || 0,
|
|
@@ -204,36 +320,9 @@ const interpretFile = (CLI, content) => {
|
|
|
204
320
|
return true
|
|
205
321
|
}
|
|
206
322
|
|
|
207
|
-
importCmd.fn = async (CLI, filename) => {
|
|
208
|
-
cli = CLI
|
|
209
|
-
if (!filename) {
|
|
210
|
-
CLI.error('Missing filename.')
|
|
211
|
-
return CLI.do('help', 'import')
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
let content
|
|
215
|
-
try {
|
|
216
|
-
content = await fs.readFile(expandPath(filename), 'utf8')
|
|
217
|
-
} catch (fsError) {
|
|
218
|
-
if (fsError.code === 'ENOENT' && !filename.endsWith('.trx')) {
|
|
219
|
-
try {
|
|
220
|
-
content = await fs.readFile(expandPath(filename + '.trx'), 'utf8')
|
|
221
|
-
} catch {
|
|
222
|
-
content = null
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!content) return CLI.error(fsError.toString())
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (!interpretFile(CLI, content)) {
|
|
230
|
-
CLI.error(filename + ' does not appear to be formatted correctly.')
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
323
|
/** Get all the notes for a certain move.
|
|
235
|
-
* @
|
|
236
|
-
* @
|
|
324
|
+
* @param {number} move - The move number
|
|
325
|
+
* @param {GameNotes} [notes] - The notes for a game
|
|
237
326
|
* @returns {string} - Notes formatted for this move, or empty string if none
|
|
238
327
|
*/
|
|
239
328
|
const gameNotes = (move, notes) => {
|
|
@@ -247,18 +336,18 @@ const gameNotes = (move, notes) => {
|
|
|
247
336
|
)
|
|
248
337
|
}
|
|
249
338
|
|
|
250
|
-
/** Join two strings together with
|
|
251
|
-
*
|
|
252
|
-
* @
|
|
253
|
-
* @
|
|
339
|
+
/** Join two strings together with the appropriate joining character
|
|
340
|
+
* depending on the combined length of the strings.
|
|
341
|
+
* @param {string} a - the first string
|
|
342
|
+
* @param {string} b - the second string
|
|
254
343
|
* @returns {string} - both strings correctly joined
|
|
255
344
|
*/
|
|
256
345
|
const join = (a, b) =>
|
|
257
346
|
a.length === 0 ? b : a.length + b.length > 78 ? a + '\n' + b : a + ' ' + b
|
|
258
347
|
|
|
259
348
|
/** Interleave the game moves with any notes, formatted for the export file.
|
|
260
|
-
* @
|
|
261
|
-
* @
|
|
349
|
+
* @param {string[]} moves - an array of move notations
|
|
350
|
+
* @param {GameNotes} [notes] - The notes for a game
|
|
262
351
|
* @returns {string} - A string that can be placed into an export file
|
|
263
352
|
*/
|
|
264
353
|
const interleaveNotation = (moves, notes) => {
|
|
@@ -293,70 +382,10 @@ const interleaveNotation = (moves, notes) => {
|
|
|
293
382
|
return content
|
|
294
383
|
}
|
|
295
384
|
|
|
296
|
-
|
|
297
|
-
cli = CLI
|
|
298
|
-
let game = CLI.GAME
|
|
299
|
-
if (id) {
|
|
300
|
-
const newid = id.replace(/^#/, '')
|
|
301
|
-
game = CLI.GAMES[newid]
|
|
302
|
-
if (!game) {
|
|
303
|
-
filename = id
|
|
304
|
-
game = CLI.GAME
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (!game || !game.id) {
|
|
309
|
-
return CLI.error('No active game. Type "new" to start a game.')
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const trax = new Trax(game.rules, game.moves, 'cli')
|
|
313
|
-
|
|
314
|
-
let content = Trax.names[trax.rules].replace(' ', '') + '\n'
|
|
315
|
-
content += game.players?.[0] || 'white'
|
|
316
|
-
content += ' vs '
|
|
317
|
-
content += game.players?.[1] || 'black'
|
|
318
|
-
content += '\n; @slugbugblue/trax cli.js v' + CLI.version + '\n'
|
|
319
|
-
if (game.puzzle) {
|
|
320
|
-
const puzzle = puzzles.find((p) => p.id === game.puzzle)
|
|
321
|
-
if (puzzle) {
|
|
322
|
-
const source = sources[puzzle.src]
|
|
323
|
-
content += '; Puzzle ' + game.puzzle
|
|
324
|
-
if (source) {
|
|
325
|
-
content += source.copyright
|
|
326
|
-
? ' ©' + source.copyright + ' by '
|
|
327
|
-
: ' provided courtesy of '
|
|
328
|
-
content += source.name
|
|
329
|
-
if (source.url) content += '\n; ' + source.url
|
|
330
|
-
if (source.license) {
|
|
331
|
-
content += '\n; Licensed under ' + source.license
|
|
332
|
-
content += source.licenseUrl ? ' ' + source.licenseUrl : ''
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
content += '\n'
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
content += interleaveNotation(trax.moves, game.notes)
|
|
341
|
-
|
|
342
|
-
filename &&= filename.replaceAll(/[^ a-z\d.~/]/g, '')
|
|
343
|
-
if (filename) {
|
|
344
|
-
const fname = filename.split('/').pop()
|
|
345
|
-
if (!fname) filename += game.rules + game.id
|
|
346
|
-
if (!fname.includes('.')) filename += '.trx'
|
|
347
|
-
CLI.out(CLI.color('Exporting #' + game.id + ' to ') + filename)
|
|
348
|
-
try {
|
|
349
|
-
fs.writeFile(expandPath(filename), content)
|
|
350
|
-
} catch (error) {
|
|
351
|
-
fsOuch(error)
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
CLI.out(content)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
385
|
+
/** @type {Record<string, string[]>} */
|
|
358
386
|
const FOLDERS = {}
|
|
359
387
|
|
|
388
|
+
/** @param {string} [path] @returns {string[]} */
|
|
360
389
|
export const findFiles = (path) => {
|
|
361
390
|
if (typeof path !== 'string') path = ''
|
|
362
391
|
let folder = '.'
|
|
@@ -371,7 +400,6 @@ export const findFiles = (path) => {
|
|
|
371
400
|
if (!FOLDERS[prefix]) {
|
|
372
401
|
FOLDERS[prefix] = []
|
|
373
402
|
fs.readdir(folder, { withFileTypes: true })
|
|
374
|
-
// eslint-disable-next-line promise/prefer-await-to-then
|
|
375
403
|
.then((dir) => {
|
|
376
404
|
// At this point, everything is happening asynchronously,
|
|
377
405
|
// so we have to call findFiles at least twice before we
|
|
@@ -387,7 +415,6 @@ export const findFiles = (path) => {
|
|
|
387
415
|
|
|
388
416
|
FOLDERS[prefix] = entries
|
|
389
417
|
})
|
|
390
|
-
// eslint-disable-next-line promise/prefer-await-to-then
|
|
391
418
|
.catch(() => {
|
|
392
419
|
// Errors are unimportant and should be silently ignored
|
|
393
420
|
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2025-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Load all the commands here and put them in a list.
|
|
8
|
+
// This just keeps the main program a tiny bit cleaner.
|
|
9
|
+
|
|
10
|
+
import { analyzeCmd } from './analyze.js'
|
|
11
|
+
import { deleteCmd } from './delete.js'
|
|
12
|
+
import { helpCmd } from './help.js'
|
|
13
|
+
import { importCmd, exportCmd } from './import-export.js'
|
|
14
|
+
import { listCmd } from './list.js'
|
|
15
|
+
import { newCmd } from './new.js'
|
|
16
|
+
import { notesCmd } from './notes.js'
|
|
17
|
+
import { playCmd, tryCmd } from './play-try.js'
|
|
18
|
+
import { puzzlesCmd } from './puzzles.js'
|
|
19
|
+
import { selectCmd } from './select.js'
|
|
20
|
+
import { suggestCmd } from './suggest.js'
|
|
21
|
+
import { undoCmd } from './undo.js'
|
|
22
|
+
import { viewCmd } from './view.js'
|
|
23
|
+
|
|
24
|
+
export const allCmds = [
|
|
25
|
+
analyzeCmd,
|
|
26
|
+
deleteCmd,
|
|
27
|
+
helpCmd,
|
|
28
|
+
importCmd,
|
|
29
|
+
exportCmd,
|
|
30
|
+
listCmd,
|
|
31
|
+
newCmd,
|
|
32
|
+
notesCmd,
|
|
33
|
+
playCmd,
|
|
34
|
+
tryCmd,
|
|
35
|
+
puzzlesCmd,
|
|
36
|
+
selectCmd,
|
|
37
|
+
suggestCmd,
|
|
38
|
+
undoCmd,
|
|
39
|
+
viewCmd,
|
|
40
|
+
]
|
package/src/cmds/list.js
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* You may obtain a copy of the License at
|
|
6
|
-
*
|
|
7
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
*
|
|
9
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
* See the License for the specific language governing permissions and
|
|
13
|
-
* limitations under the License.
|
|
1
|
+
/** CLI list command
|
|
2
|
+
* @copyright 2022-2026
|
|
3
|
+
* @author Chad Transtrum <chad@transtrum.net>
|
|
4
|
+
* @license Apache-2.0
|
|
14
5
|
*/
|
|
15
6
|
|
|
16
|
-
|
|
7
|
+
/** @typedef {import('../cli.js').CLIContext} CLIContext */
|
|
17
8
|
|
|
18
9
|
import process from 'node:process'
|
|
19
10
|
import { Trax } from '@slugbugblue/trax'
|
|
@@ -39,8 +30,75 @@ export const listCmd = {
|
|
|
39
30
|
" <other text>: match a player's name or the latest game note",
|
|
40
31
|
'Add the "reverse" option to reverse the sort order.',
|
|
41
32
|
],
|
|
33
|
+
/** @param {CLIContext} CLI @param {...string} filters */
|
|
34
|
+
fn(CLI, ...filters) {
|
|
35
|
+
if (Object.keys(CLI.GAMES).length === 0) {
|
|
36
|
+
return CLI.error('No games. Type "new" to start a new game.')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
|
|
40
|
+
if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
|
|
41
|
+
|
|
42
|
+
/** @type {Array<Record<string, string | number | boolean | undefined>>} */
|
|
43
|
+
let list = []
|
|
44
|
+
const size = {
|
|
45
|
+
spacing: 13, // Amount of space taken up by fixed-length and between columns
|
|
46
|
+
id: 1,
|
|
47
|
+
name: 1,
|
|
48
|
+
moves: 1,
|
|
49
|
+
p1: 1,
|
|
50
|
+
p2: 1,
|
|
51
|
+
}
|
|
52
|
+
let noteSize = Number.POSITIVE_INFINITY
|
|
53
|
+
|
|
54
|
+
for (const game of Object.values(CLI.GAMES)) {
|
|
55
|
+
const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
|
|
56
|
+
list.push({
|
|
57
|
+
sid: String(game.id).padStart(9, '0'),
|
|
58
|
+
id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
|
|
59
|
+
name: game.name?.length || 0,
|
|
60
|
+
rules: game.rules,
|
|
61
|
+
moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
|
|
62
|
+
smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
|
|
63
|
+
sturn: String(game.turn),
|
|
64
|
+
p1: game.players?.[0] || 'white',
|
|
65
|
+
p2: game.players?.[1] || 'black',
|
|
66
|
+
turn: game.turn,
|
|
67
|
+
over: game.over,
|
|
68
|
+
note: game.notes?.at(-1)?.note || '',
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
list = listSort(list, filters)
|
|
73
|
+
|
|
74
|
+
for (const game of list) {
|
|
75
|
+
size.id = Math.max(size.id, String(game.id).length)
|
|
76
|
+
size.name = Math.max(size.name, Number(game.name))
|
|
77
|
+
size.moves = Math.max(size.moves, String(game.moves).length)
|
|
78
|
+
size.p1 = Math.max(size.p1, String(game.p1).length)
|
|
79
|
+
size.p2 = Math.max(size.p2, String(game.p2).length)
|
|
80
|
+
noteSize = Math.min(noteSize, leftover(size))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const game of list) {
|
|
84
|
+
CLI.out(
|
|
85
|
+
CLI.color(String(game.id).padStart(size.id) + ' ') +
|
|
86
|
+
Trax.names[
|
|
87
|
+
/** @type {import('../cli.js').TraxVariant} */ (String(game.rules))
|
|
88
|
+
] +
|
|
89
|
+
' '.repeat(size.name - Number(game.name) + 1) +
|
|
90
|
+
CLI.bubble(game.turn === 1 ? 'wh' : 'w', String(game.p1)) +
|
|
91
|
+
CLI.color('vs '.padStart(size.p1 - String(game.p1).length + 4)) +
|
|
92
|
+
CLI.bubble(game.turn === 2 ? 'bh' : 'b', String(game.p2)) +
|
|
93
|
+
CLI.color(' '.padStart(size.p2 - String(game.p2).length + 1)) +
|
|
94
|
+
CLI.color(String(game.moves).padEnd(size.moves)) +
|
|
95
|
+
CLI.color.help(' ' + String(game.note).slice(0, noteSize)),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
42
99
|
}
|
|
43
100
|
|
|
101
|
+
/** @param {string} a @param {string} b @returns {number} */
|
|
44
102
|
const alphaSort = (a, b) => {
|
|
45
103
|
a = String(a).toLowerCase()
|
|
46
104
|
b = String(b).toLowerCase()
|
|
@@ -49,6 +107,7 @@ const alphaSort = (a, b) => {
|
|
|
49
107
|
return 0
|
|
50
108
|
}
|
|
51
109
|
|
|
110
|
+
/** @param {string | undefined} sort @returns {string} */
|
|
52
111
|
const sortBy = (sort) => {
|
|
53
112
|
if (!sort) return 'id'
|
|
54
113
|
sort = sort.toLowerCase()
|
|
@@ -67,6 +126,11 @@ const sortBy = (sort) => {
|
|
|
67
126
|
return sort
|
|
68
127
|
}
|
|
69
128
|
|
|
129
|
+
/**
|
|
130
|
+
* @param {Array<Record<string, string | number | boolean | undefined>>} list
|
|
131
|
+
* @param {string[]} filters
|
|
132
|
+
* @returns {Array<Record<string, string | number | boolean | undefined>>}
|
|
133
|
+
*/
|
|
70
134
|
const listSort = (list, filters) => {
|
|
71
135
|
if (list.length === 0) return list
|
|
72
136
|
let reverse = false
|
|
@@ -75,7 +139,7 @@ const listSort = (list, filters) => {
|
|
|
75
139
|
if (by === 'reverse') {
|
|
76
140
|
reverse = true
|
|
77
141
|
} else if (list[0]?.[by]) {
|
|
78
|
-
list.sort((a, b) => alphaSort(a[by], b[by]))
|
|
142
|
+
list.sort((a, b) => alphaSort(String(a[by]), String(b[by])))
|
|
79
143
|
} else if (by === 'trax') {
|
|
80
144
|
list = list.filter((g) => g.rules === by)
|
|
81
145
|
} else if (['over', 'active'].includes(by)) {
|
|
@@ -83,12 +147,16 @@ const listSort = (list, filters) => {
|
|
|
83
147
|
} else {
|
|
84
148
|
list = list.filter((g) => {
|
|
85
149
|
return (
|
|
86
|
-
g.id.endsWith(by) ||
|
|
87
|
-
Trax.names[
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
String(g.id).endsWith(by) ||
|
|
151
|
+
Trax.names[
|
|
152
|
+
/** @type {import('../cli.js').TraxVariant} */ (String(g.rules))
|
|
153
|
+
]
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.includes(by) ||
|
|
156
|
+
String(g.p1).toLowerCase().includes(by) ||
|
|
157
|
+
String(g.p2).toLowerCase().includes(by) ||
|
|
158
|
+
String(g.moves).startsWith(by + ' ') ||
|
|
159
|
+
String(g.note).includes(by)
|
|
92
160
|
)
|
|
93
161
|
})
|
|
94
162
|
}
|
|
@@ -99,9 +167,9 @@ const listSort = (list, filters) => {
|
|
|
99
167
|
return list
|
|
100
168
|
}
|
|
101
169
|
|
|
102
|
-
/** Find how many columns are left over after all the other columns
|
|
103
|
-
* @
|
|
104
|
-
* @returns {number}
|
|
170
|
+
/** Find how many columns are left over after all the other columns are filled.
|
|
171
|
+
* @param {Record<string, number>} sizes
|
|
172
|
+
* @returns {number}
|
|
105
173
|
*/
|
|
106
174
|
const leftover = (sizes) => {
|
|
107
175
|
let left = process.stdout.columns
|
|
@@ -111,66 +179,3 @@ const leftover = (sizes) => {
|
|
|
111
179
|
|
|
112
180
|
return left
|
|
113
181
|
}
|
|
114
|
-
|
|
115
|
-
listCmd.fn = (CLI, ...filters) => {
|
|
116
|
-
if (Object.keys(CLI.GAMES).length === 0) {
|
|
117
|
-
return CLI.error('No games. Type "new" to start a new game.')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const puzzle = filters.find((f) => f.length > 1 && 'puzzles'.startsWith(f))
|
|
121
|
-
if (puzzle) return CLI.do('puzzles', ...filters.filter((f) => f !== puzzle))
|
|
122
|
-
|
|
123
|
-
let list = []
|
|
124
|
-
const size = {
|
|
125
|
-
spacing: 13, // Amount of space taken up by fixed-length and between columns
|
|
126
|
-
id: 1,
|
|
127
|
-
name: 1,
|
|
128
|
-
moves: 1,
|
|
129
|
-
p1: 1,
|
|
130
|
-
p2: 1,
|
|
131
|
-
}
|
|
132
|
-
let noteSize = Number.POSITIVE_INFINITY
|
|
133
|
-
|
|
134
|
-
for (const game of Object.values(CLI.GAMES)) {
|
|
135
|
-
const moves = game.moves.length === 0 ? 0 : game.moves.split(' ').length
|
|
136
|
-
list.push({
|
|
137
|
-
sid: String(game.id).padStart(9, '0'),
|
|
138
|
-
id: (game.id === CLI.GAME.id ? '* #' : '#') + String(game.id),
|
|
139
|
-
name: game.name?.length || 0,
|
|
140
|
-
rules: game.rules,
|
|
141
|
-
moves: game.over ? 'game over' : CLI.plural(moves, 'move'),
|
|
142
|
-
smoves: game.over ? '9999' : String(moves).padStart(9, '0'),
|
|
143
|
-
sturn: String(game.turn),
|
|
144
|
-
p1: game.players?.[0] || 'white',
|
|
145
|
-
p2: game.players?.[1] || 'black',
|
|
146
|
-
turn: game.turn,
|
|
147
|
-
over: game.over,
|
|
148
|
-
note: game.notes?.[game.notes.length - 1]?.note || '',
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
list = listSort(list, filters)
|
|
153
|
-
|
|
154
|
-
for (const game of list) {
|
|
155
|
-
size.id = Math.max(size.id, game.id.length)
|
|
156
|
-
size.name = Math.max(size.name, game.name)
|
|
157
|
-
size.moves = Math.max(size.moves, game.moves.length)
|
|
158
|
-
size.p1 = Math.max(size.p1, game.p1.length)
|
|
159
|
-
size.p2 = Math.max(size.p2, game.p2.length)
|
|
160
|
-
noteSize = Math.min(noteSize, leftover(size))
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (const game of list) {
|
|
164
|
-
CLI.out(
|
|
165
|
-
CLI.color(game.id.padStart(size.id) + ' ') +
|
|
166
|
-
Trax.names[game.rules] +
|
|
167
|
-
' '.repeat(size.name - game.name + 1) +
|
|
168
|
-
CLI.bubble(game.turn === 1 ? 'wh' : 'w', game.p1) +
|
|
169
|
-
CLI.color('vs '.padStart(size.p1 - game.p1.length + 4)) +
|
|
170
|
-
CLI.bubble(game.turn === 2 ? 'bh' : 'b', game.p2) +
|
|
171
|
-
CLI.color(' '.padStart(size.p2 - game.p2.length + 1)) +
|
|
172
|
-
CLI.color(game.moves.padEnd(size.moves)) +
|
|
173
|
-
CLI.color.help(' ' + game.note.slice(0, noteSize)),
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
}
|