Skip to content

Commit 0ceebf9

Browse files
authored
Merge pull request #68 from puzzmo-com/cli
Adds a CLI to xd-crossword-tools
2 parents e523037 + 4fa073b commit 0ceebf9

File tree

5 files changed

+177
-4
lines changed

5 files changed

+177
-4
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4213,6 +4213,26 @@ O..O.#O.O##O..O
42134213

42144214
</details>
42154215

4216+
## CLI
4217+
4218+
You can convert crossword files to `.xd` format directly from the command line without installing outside of Node.js:
4219+
4220+
```sh
4221+
# Convert a .puz file
4222+
npx xd-crossword-tools puzzle.puz -o ./output
4223+
4224+
# Convert multiple files at once
4225+
npx xd-crossword-tools *.puz *.jpz -o ./xd-files
4226+
4227+
# Convert a PuzzleMe URL
4228+
npx xd-crossword-tools "https://puzzleme.amuselabs.com/pmm/crossword?id=abc123&set=..." -o ./output
4229+
4230+
# Mix files and URLs
4231+
npx xd-crossword-tools puzzle.puz "https://puzzleme.amuselabs.com/pmm/crossword?id=abc123&set=..." -o ./output
4232+
```
4233+
4234+
Supported input formats: `.puz`, `.jpz`, `.xml` (UClick), `.json` (Amuse Labs), `.txt` (Across text), and URLs which contain PuzzleMe crosswords.
4235+
42164236
## Import / Export
42174237

42184238
### .xd to .JSON

packages/xd-crossword-tools-parser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"type": "git",
1616
"url": "git+https://github.com/puzzmo-com/xd-crossword-tools.git"
1717
},
18-
"homepage": "https://puzzmo-com.github.io/xd-crossword-tools/",
18+
"homepage": "https://xd-crossword-tools.com/",
1919
"bugs": {
2020
"url": "https://github.com/puzzmo-com/xd-crossword-tools/issues"
2121
},

packages/xd-crossword-tools/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
"main": "dist/index.js",
55
"module": "./dist/index.mjs",
66
"types": "./dist/index.d.ts",
7+
"bin": "./dist/cli.js",
78
"scripts": {
8-
"build": "tsup src/index.ts --dts --format esm,cjs",
9-
"dev": "tsup src/index.ts --dts --format esm,cjs --watch",
9+
"build": "tsup src/index.ts src/cli.ts --dts --format esm,cjs",
10+
"dev": "tsup src/index.ts src/cli.ts --dts --format esm,cjs --watch",
1011
"type-check": "tsc --noEmit",
1112
"prepublishOnly": "cp ../../README.md ./README.md",
1213
"postpublish": "rm ./README.md"
@@ -23,7 +24,7 @@
2324
"type": "git",
2425
"url": "git+https://github.com/puzzmo-com/xd-crossword-tools.git"
2526
},
26-
"homepage": "https://puzzmo-com.github.io/xd-crossword-tools/",
27+
"homepage": "https://xd-crossword-tools.com",
2728
"bugs": {
2829
"url": "https://github.com/puzzmo-com/xd-crossword-tools/issues"
2930
},
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env node
2+
3+
import * as fs from "fs"
4+
import * as path from "path"
5+
6+
import { puzToXD } from "./puzToXD"
7+
import { jpzToXD } from "./jpzToXD"
8+
import { uclickXMLToXD } from "./uclickToXD"
9+
import { amuseToXD } from "./amuseJSONToXD"
10+
import { acrossTextToXD } from "./acrossTextToXD"
11+
import { decodePuzzleMeHTML } from "./puzzleMeDecode"
12+
13+
const SUPPORTED_EXTENSIONS = [".puz", ".jpz", ".xml", ".json", ".txt"]
14+
15+
function usage(): never {
16+
console.log(`Usage: xd-crossword-tools <input-files...> -o <output-dir>
17+
18+
Converts crossword puzzle files to .xd format.
19+
20+
Supported input formats:
21+
.puz - Across Lite binary format
22+
.jpz - JPZ XML format
23+
.xml - UClick XML format
24+
.json - Amuse Labs JSON format
25+
.txt - Across text format
26+
URL - PuzzleMe URL (https://puzzleme.amuselabs.com/...)
27+
28+
Options:
29+
-o, --output <dir> Output directory (default: current directory)
30+
-h, --help Show this help message
31+
32+
Examples:
33+
xd-crossword-tools puzzle.puz -o ./converted
34+
xd-crossword-tools *.puz *.jpz -o ./xd-files
35+
xd-crossword-tools https://puzzleme.amuselabs.com/pmm/crossword?id=abc -o ./converted`)
36+
process.exit(0)
37+
}
38+
39+
function convertFile(filePath: string): string {
40+
const ext = path.extname(filePath).toLowerCase()
41+
42+
switch (ext) {
43+
case ".puz": {
44+
const buffer = fs.readFileSync(filePath)
45+
return puzToXD(buffer)
46+
}
47+
case ".jpz": {
48+
const content = fs.readFileSync(filePath, "utf-8")
49+
return jpzToXD(content)
50+
}
51+
case ".xml": {
52+
const content = fs.readFileSync(filePath, "utf-8")
53+
return uclickXMLToXD(content)
54+
}
55+
case ".json": {
56+
const content = fs.readFileSync(filePath, "utf-8")
57+
const json = JSON.parse(content)
58+
return amuseToXD(json)
59+
}
60+
case ".txt": {
61+
const content = fs.readFileSync(filePath, "utf-8")
62+
return acrossTextToXD(content)
63+
}
64+
default:
65+
throw new Error(`Unsupported file format: ${ext}`)
66+
}
67+
}
68+
69+
async function convertURL(url: string): Promise<string> {
70+
const response = await fetch(url)
71+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
72+
const html = await response.text()
73+
const amuse = decodePuzzleMeHTML(html)
74+
return amuseToXD(amuse)
75+
}
76+
77+
async function main() {
78+
const args = process.argv.slice(2)
79+
80+
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
81+
usage()
82+
}
83+
84+
let outputDir = "."
85+
const inputs: string[] = []
86+
87+
for (let i = 0; i < args.length; i++) {
88+
if (args[i] === "-o" || args[i] === "--output") {
89+
outputDir = args[++i]
90+
if (!outputDir) {
91+
console.error("Error: -o requires a directory argument")
92+
process.exit(1)
93+
}
94+
} else if (args[i].startsWith("-")) {
95+
console.error(`Unknown option: ${args[i]}`)
96+
process.exit(1)
97+
} else {
98+
inputs.push(args[i])
99+
}
100+
}
101+
102+
if (inputs.length === 0) {
103+
console.error("Error: no input files specified")
104+
process.exit(1)
105+
}
106+
107+
// Create output directory if needed
108+
fs.mkdirSync(outputDir, { recursive: true })
109+
110+
let converted = 0
111+
let failed = 0
112+
113+
for (const input of inputs) {
114+
const isURL = input.startsWith("http://") || input.startsWith("https://")
115+
116+
try {
117+
let xd: string
118+
let outputName: string
119+
120+
if (isURL) {
121+
const url = new URL(input)
122+
const id = url.searchParams.get("id") ?? url.pathname.split("/").pop() ?? "puzzle"
123+
outputName = id
124+
xd = await convertURL(input)
125+
} else {
126+
const ext = path.extname(input).toLowerCase()
127+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
128+
console.error(`Skipping ${input}: unsupported format (${ext})`)
129+
failed++
130+
continue
131+
}
132+
outputName = path.basename(input, ext)
133+
xd = convertFile(input)
134+
}
135+
136+
const outputPath = path.join(outputDir, `${outputName}.xd`)
137+
fs.writeFileSync(outputPath, xd)
138+
console.log(`${input} -> ${outputPath}`)
139+
converted++
140+
} catch (err: any) {
141+
console.error(`Error converting ${input}: ${err.message}`)
142+
failed++
143+
}
144+
}
145+
146+
console.log(`\nDone: ${converted} converted, ${failed} failed`)
147+
if (failed > 0) process.exit(1)
148+
}
149+
150+
main()

yarn.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6546,6 +6546,8 @@ __metadata:
65466546
tsup: "npm:^8.1.0"
65476547
xd-crossword-tools-parser: "workspace:*"
65486548
xml-parser: "npm:1.2.1"
6549+
bin:
6550+
xd-crossword-tools: ./dist/cli.js
65496551
languageName: unknown
65506552
linkType: soft
65516553

0 commit comments

Comments
 (0)