endef 2.0.3 → 2.0.5
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/README.md +22 -8
- package/package.json +1 -1
- package/src/config.js +2 -1
- package/src/de.js +245 -32
- package/src/en.js +3 -1
- package/src/run.js +20 -24
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`endef` 是一个纯 Node.js 的文件旁路转换工具。
|
|
4
4
|
|
|
5
|
-
它适合这样的场景:某些常见源码或文本后缀的文件在系统读取、复制、粘贴、编辑器打开时出现乱码或异常,但通过 Node.js 脚本读取仍然正常。`endef` 会先把目标文件内容写到同目录的 `.endef`
|
|
5
|
+
它适合这样的场景:某些常见源码或文本后缀的文件在系统读取、复制、粘贴、编辑器打开时出现乱码或异常,但通过 Node.js 脚本读取仍然正常。`endef` 会先把目标文件内容写到同目录的 `.endef` 副本里,再用脚本批量删除原文件并把 `.endef` 改回原文件名。
|
|
6
6
|
|
|
7
7
|
示例:
|
|
8
8
|
|
|
@@ -16,10 +16,10 @@ README.md -> README.md.endef -> README.md
|
|
|
16
16
|
|
|
17
17
|
## 特性
|
|
18
18
|
|
|
19
|
-
-
|
|
19
|
+
- Node.js CLI,恢复阶段可自动调用 Bash 或 PowerShell 脚本
|
|
20
20
|
- 默认旁路后缀为 `.endef`
|
|
21
21
|
- `en` 默认转换所有匹配后缀的文件
|
|
22
|
-
- `de`
|
|
22
|
+
- `de` 会用脚本批量删除原文件,并把 `.endef` 临时副本改回原文件名
|
|
23
23
|
- `pack` 会把恢复后的目录打包成 `.tar.gz`
|
|
24
24
|
- 不引入生产依赖
|
|
25
25
|
|
|
@@ -41,7 +41,7 @@ npm install -g endef
|
|
|
41
41
|
endef en /path/to/project
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
用脚本批量删除原文件,并把 `.endef` 改回原文件名:
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
endef de /path/to/project
|
|
@@ -82,11 +82,11 @@ endef all [directory] [options]
|
|
|
82
82
|
|
|
83
83
|
`en` 会扫描目标目录,把匹配后缀的文件读取出来,并写成同目录的 `.endef` 副本。
|
|
84
84
|
|
|
85
|
-
`de` 会扫描目标目录中的 `.endef`
|
|
85
|
+
`de` 会扫描目标目录中的 `.endef` 文件,并用外部脚本批量删除原文件、把 `.endef` 改回原文件名。默认模式是 `auto`:优先使用 Bash 的 `find + xargs -P`,没有 Bash 时在 Windows 上使用 PowerShell。这个命令会直接修改文件,请在执行前确认目录和备份策略。
|
|
86
86
|
|
|
87
87
|
`pack` 会按当前配置扫描当前目录,把没有被 `excludeDirs` 排除的文件打包成 `.tar.gz`。如果输出文件已存在,会直接覆盖。
|
|
88
88
|
|
|
89
|
-
`all`
|
|
89
|
+
`all` 会按顺序执行 `en`、`de`、`pack`,适合确认配置后一次性完成旁路转换、脚本恢复和打包。
|
|
90
90
|
|
|
91
91
|
## 参数
|
|
92
92
|
|
|
@@ -97,6 +97,8 @@ endef all [directory] [options]
|
|
|
97
97
|
--exclude <list> 排除目录名,多个值用英文逗号分隔
|
|
98
98
|
--concurrency <n> 并发文件操作数量
|
|
99
99
|
--out <file> 压缩包输出名称
|
|
100
|
+
--mode <mode> de/all 恢复模式:auto、bash、powershell、node
|
|
101
|
+
--verbose 输出处理明细
|
|
100
102
|
```
|
|
101
103
|
|
|
102
104
|
示例:
|
|
@@ -166,13 +168,25 @@ endef all . --out recovered
|
|
|
166
168
|
file.ext.endef
|
|
167
169
|
```
|
|
168
170
|
|
|
169
|
-
|
|
171
|
+
恢复为:
|
|
170
172
|
|
|
171
173
|
```text
|
|
172
174
|
file.ext
|
|
173
175
|
```
|
|
174
176
|
|
|
175
|
-
`de`
|
|
177
|
+
`de` 默认使用脚本恢复。`auto` 模式会优先尝试 Bash,因为 `find + xargs -P` 可以并行处理大量文件;如果没有 Bash,则在 Windows 上使用单个 PowerShell 进程批量处理。恢复时会把:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
file.ext.endef
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
改回:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
file.ext
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
如果 `file.ext` 已经存在,会先被删除。这个行为符合工具的核心假设:原文件可能已经被异常内容污染,而 `.endef` 是通过旁路读取保存出来的可恢复内容。
|
|
176
190
|
|
|
177
191
|
## 打包策略
|
|
178
192
|
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -35,7 +35,8 @@ const defaultConfig = {
|
|
|
35
35
|
],
|
|
36
36
|
excludeDirs: ['node_modules', '.git', 'dist', '.next', '.nuxt', '.cache'],
|
|
37
37
|
excludeFiles: ['.endef.json'],
|
|
38
|
-
concurrency: 32
|
|
38
|
+
concurrency: 32,
|
|
39
|
+
restoreMode: 'auto'
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
function loadConfig(cwd, explicitConfigFile, targetDirectory) {
|
package/src/de.js
CHANGED
|
@@ -1,48 +1,261 @@
|
|
|
1
1
|
const fs = require('fs/promises')
|
|
2
2
|
const path = require('path')
|
|
3
|
+
const { execFile } = require('child_process')
|
|
4
|
+
const { promisify } = require('util')
|
|
3
5
|
const { defaultConfig } = require('./config')
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
createPackConfig,
|
|
7
|
-
extractTarGz,
|
|
8
|
-
removeSidecars,
|
|
9
|
-
writeTarGz
|
|
10
|
-
} = require('./pack')
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
11
8
|
|
|
12
9
|
async function restoreFiles(directory, options = {}) {
|
|
13
|
-
const config =
|
|
10
|
+
const config = {
|
|
14
11
|
...defaultConfig,
|
|
15
|
-
...options
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
...options,
|
|
13
|
+
directory: path.resolve(directory || process.cwd()),
|
|
14
|
+
restoreMode: options.restoreMode || 'auto'
|
|
15
|
+
}
|
|
16
|
+
const mode = await resolveRestoreMode(config.restoreMode)
|
|
17
|
+
|
|
18
|
+
if (mode === 'bash') {
|
|
19
|
+
return restoreWithBash(config)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (mode === 'powershell') {
|
|
23
|
+
return restoreWithPowerShell(config)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return restoreWithNode(config)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function resolveRestoreMode(mode) {
|
|
30
|
+
if (mode !== 'auto') {
|
|
31
|
+
return mode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (await hasCommand('bash', ['--version'])) {
|
|
35
|
+
return 'bash'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.platform === 'win32') {
|
|
39
|
+
return 'powershell'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return 'node'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function hasCommand(command, args) {
|
|
46
|
+
try {
|
|
47
|
+
await execFileAsync(command, args, { windowsHide: true })
|
|
48
|
+
return true
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function restoreWithBash(config) {
|
|
55
|
+
const root = await toBashPath(config.directory)
|
|
56
|
+
const excludeDirs = config.excludeDirs.join('\n')
|
|
57
|
+
const jobs = String(config.concurrency || 32)
|
|
58
|
+
const script = `
|
|
59
|
+
set -e
|
|
60
|
+
root=$1
|
|
61
|
+
suffix=$2
|
|
62
|
+
jobs=$3
|
|
63
|
+
cd "$root"
|
|
64
|
+
find_args=(.)
|
|
65
|
+
while IFS= read -r dir; do
|
|
66
|
+
if [ -n "$dir" ]; then
|
|
67
|
+
find_args+=( -path "*/$dir" -prune -o )
|
|
68
|
+
fi
|
|
69
|
+
done <<< "$ENDEF_EXCLUDE_DIRS"
|
|
70
|
+
find_args+=( -type f -name "*$suffix" -print0 )
|
|
71
|
+
find "\${find_args[@]}" | xargs -0 -r -P "$jobs" -I{} bash -c '
|
|
72
|
+
suffix="$1"
|
|
73
|
+
file="$2"
|
|
74
|
+
target="\${file%$suffix}"
|
|
75
|
+
rm -f -- "$target"
|
|
76
|
+
mv -f -- "$file" "$target"
|
|
77
|
+
printf "%s\\0" "$target"
|
|
78
|
+
' _ "$suffix" {}
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
const { stdout } = await execFileAsync('bash', ['-lc', script, '_', root, config.suffix, jobs], {
|
|
82
|
+
env: {
|
|
83
|
+
...process.env,
|
|
84
|
+
ENDEF_EXCLUDE_DIRS: excludeDirs
|
|
85
|
+
},
|
|
86
|
+
maxBuffer: 1024 * 1024 * 64,
|
|
87
|
+
windowsHide: true
|
|
21
88
|
})
|
|
89
|
+
const restoredFiles = splitNullSeparated(stdout)
|
|
90
|
+
const restored = restoredFiles.length
|
|
22
91
|
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
92
|
+
if (config.verbose) {
|
|
93
|
+
restoredFiles.forEach(file => {
|
|
94
|
+
console.log(`Restored: ${file}`)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`Done. mode=bash, restored=${restored}, failed=0`)
|
|
99
|
+
return {
|
|
100
|
+
mode: 'bash',
|
|
101
|
+
restored,
|
|
102
|
+
failed: 0
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function toBashPath(filePath) {
|
|
107
|
+
if (process.platform !== 'win32') {
|
|
108
|
+
return filePath
|
|
30
109
|
}
|
|
31
110
|
|
|
32
111
|
try {
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
112
|
+
const { stdout } = await execFileAsync('bash', ['-lc', 'cygpath -u "$1"', '_', filePath], {
|
|
113
|
+
windowsHide: true
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return stdout.trim() || filePath
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return filePath
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function restoreWithPowerShell(config) {
|
|
123
|
+
const script = `
|
|
124
|
+
$root = $env:ENDEF_ROOT
|
|
125
|
+
$suffix = $env:ENDEF_SUFFIX
|
|
126
|
+
$excludeText = $env:ENDEF_EXCLUDE_DIRS
|
|
127
|
+
$excludeDirs = @()
|
|
128
|
+
$verbose = $env:ENDEF_VERBOSE -eq '1'
|
|
129
|
+
if ($excludeText) {
|
|
130
|
+
$excludeDirs = $excludeText -split "\\n" | Where-Object { $_ }
|
|
131
|
+
}
|
|
132
|
+
$restored = 0
|
|
133
|
+
$restoredFiles = New-Object System.Collections.Generic.List[string]
|
|
134
|
+
Get-ChildItem -LiteralPath $root -Recurse -File -Filter "*$suffix" | Where-Object {
|
|
135
|
+
$cleanRoot = $root.TrimEnd('\\', '/')
|
|
136
|
+
$relative = $_.FullName.Substring($cleanRoot.Length).TrimStart('\\', '/')
|
|
137
|
+
$parts = $relative -split '[\\\\/]'
|
|
138
|
+
$skip = $false
|
|
139
|
+
foreach ($dir in $excludeDirs) {
|
|
140
|
+
if ($parts -contains $dir) {
|
|
141
|
+
$skip = $true
|
|
142
|
+
break
|
|
42
143
|
}
|
|
43
|
-
} finally {
|
|
44
|
-
await fs.rm(archivePath, { force: true })
|
|
45
144
|
}
|
|
145
|
+
-not $skip
|
|
146
|
+
} | ForEach-Object {
|
|
147
|
+
$target = $_.FullName.Substring(0, $_.FullName.Length - $suffix.Length)
|
|
148
|
+
Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
|
|
149
|
+
Move-Item -LiteralPath $_.FullName -Destination $target -Force
|
|
150
|
+
$restored += 1
|
|
151
|
+
if ($verbose) {
|
|
152
|
+
$restoredFiles.Add($target)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if ($verbose) {
|
|
156
|
+
foreach ($file in $restoredFiles) {
|
|
157
|
+
Write-Output "Restored: $file"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
Write-Output $restored
|
|
161
|
+
`
|
|
162
|
+
const { stdout } = await execFileAsync(
|
|
163
|
+
'powershell.exe',
|
|
164
|
+
[
|
|
165
|
+
'-NoProfile',
|
|
166
|
+
'-NonInteractive',
|
|
167
|
+
'-ExecutionPolicy',
|
|
168
|
+
'Bypass',
|
|
169
|
+
'-Command',
|
|
170
|
+
script
|
|
171
|
+
],
|
|
172
|
+
{
|
|
173
|
+
env: {
|
|
174
|
+
...process.env,
|
|
175
|
+
ENDEF_ROOT: config.directory,
|
|
176
|
+
ENDEF_SUFFIX: config.suffix,
|
|
177
|
+
ENDEF_EXCLUDE_DIRS: config.excludeDirs.join('\n'),
|
|
178
|
+
ENDEF_VERBOSE: config.verbose ? '1' : ''
|
|
179
|
+
},
|
|
180
|
+
maxBuffer: 1024 * 1024 * 64,
|
|
181
|
+
windowsHide: true
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
const lines = stdout.trim().split(/\r?\n/).filter(Boolean)
|
|
185
|
+
const restored = Number(lines[lines.length - 1]) || 0
|
|
186
|
+
|
|
187
|
+
if (config.verbose) {
|
|
188
|
+
lines.slice(0, -1).forEach(line => {
|
|
189
|
+
console.log(line)
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`Done. mode=powershell, restored=${restored}, failed=0`)
|
|
194
|
+
return {
|
|
195
|
+
mode: 'powershell',
|
|
196
|
+
restored,
|
|
197
|
+
failed: 0
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function restoreWithNode(config) {
|
|
202
|
+
const stats = {
|
|
203
|
+
restored: 0,
|
|
204
|
+
failed: 0
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await restoreNodeDirectory(config.directory, config, stats)
|
|
208
|
+
console.log(`Done. mode=node, restored=${stats.restored}, failed=${stats.failed}`)
|
|
209
|
+
return {
|
|
210
|
+
mode: 'node',
|
|
211
|
+
restored: stats.restored,
|
|
212
|
+
failed: stats.failed
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function restoreNodeDirectory(directory, config, stats) {
|
|
217
|
+
const entries = await fs.readdir(directory, { withFileTypes: true })
|
|
218
|
+
|
|
219
|
+
await Promise.all(
|
|
220
|
+
entries.map(async entry => {
|
|
221
|
+
const filePath = path.join(directory, entry.name)
|
|
222
|
+
|
|
223
|
+
if (entry.isDirectory()) {
|
|
224
|
+
if (config.excludeDirs.includes(entry.name)) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await restoreNodeDirectory(filePath, config, stats)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!entry.isFile() || !filePath.endsWith(config.suffix)) {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const targetPath = filePath.slice(0, -config.suffix.length)
|
|
238
|
+
await fs.rm(targetPath, { force: true })
|
|
239
|
+
await fs.rename(filePath, targetPath)
|
|
240
|
+
stats.restored += 1
|
|
241
|
+
if (config.verbose) {
|
|
242
|
+
console.log(`Restored: ${targetPath}`)
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
stats.failed += 1
|
|
246
|
+
console.error(`Failed: ${filePath}`)
|
|
247
|
+
console.error(error && error.message ? error.message : error)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function splitNullSeparated(value) {
|
|
254
|
+
if (!value) {
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return value.split('\0').filter(Boolean)
|
|
46
259
|
}
|
|
47
260
|
|
|
48
261
|
module.exports = restoreFiles
|
package/src/en.js
CHANGED
|
@@ -30,7 +30,9 @@ async function processFiles(directory, options = {}) {
|
|
|
30
30
|
const data = await fs.readFile(filePath)
|
|
31
31
|
|
|
32
32
|
await fs.writeFile(outputPath, data)
|
|
33
|
-
|
|
33
|
+
if (config.verbose) {
|
|
34
|
+
console.log(`Written: ${outputPath}`)
|
|
35
|
+
}
|
|
34
36
|
stats.written += 1
|
|
35
37
|
}).catch(error => {
|
|
36
38
|
stats.failed += 1
|
package/src/run.js
CHANGED
|
@@ -2,14 +2,6 @@ const enF = require('./en')
|
|
|
2
2
|
const deF = require('./de')
|
|
3
3
|
const packF = require('./pack')
|
|
4
4
|
const { loadConfig } = require('./config')
|
|
5
|
-
const {
|
|
6
|
-
collectRecoveryEntries,
|
|
7
|
-
createPackConfig,
|
|
8
|
-
extractTarGz,
|
|
9
|
-
removeSidecars,
|
|
10
|
-
resolveOutputPath,
|
|
11
|
-
writeTarGz
|
|
12
|
-
} = require('./pack')
|
|
13
5
|
|
|
14
6
|
function printHelp() {
|
|
15
7
|
console.log(`Usage:
|
|
@@ -25,6 +17,8 @@ Options:
|
|
|
25
17
|
--exclude <list> Comma separated excluded directory names
|
|
26
18
|
--concurrency <n> Concurrent file operation limit
|
|
27
19
|
--out <file> Archive output name
|
|
20
|
+
--mode <mode> Restore mode for de/all: auto, bash, powershell, node
|
|
21
|
+
--verbose Print each processed file
|
|
28
22
|
`)
|
|
29
23
|
}
|
|
30
24
|
|
|
@@ -103,6 +97,22 @@ function parseArgs(command, args) {
|
|
|
103
97
|
continue
|
|
104
98
|
}
|
|
105
99
|
|
|
100
|
+
if (arg === '--verbose') {
|
|
101
|
+
result.verbose = true
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (arg.startsWith('--mode=')) {
|
|
106
|
+
result.restoreMode = arg.slice('--mode='.length)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (arg === '--mode') {
|
|
111
|
+
result.restoreMode = args[index + 1]
|
|
112
|
+
index += 1
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
if (!arg.startsWith('-')) {
|
|
107
117
|
result.positionals.push(arg)
|
|
108
118
|
}
|
|
@@ -168,28 +178,14 @@ async function run(args) {
|
|
|
168
178
|
|
|
169
179
|
if (command === 'all') {
|
|
170
180
|
await enF(options.directory, options)
|
|
171
|
-
await
|
|
181
|
+
await deF(options.directory, options)
|
|
182
|
+
await packF(options.directory, options)
|
|
172
183
|
return
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
printHelp()
|
|
176
187
|
}
|
|
177
188
|
|
|
178
|
-
async function runAll(directory, options) {
|
|
179
|
-
const config = createPackConfig(directory, options)
|
|
180
|
-
const outputPath = resolveOutputPath(config.directory, config.out)
|
|
181
|
-
const entries = await collectRecoveryEntries(config, {
|
|
182
|
-
outputPath
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
await writeTarGz(config.directory, outputPath, entries)
|
|
186
|
-
const restored = await extractTarGz(outputPath, config.directory)
|
|
187
|
-
const deleted = await removeSidecars(config.directory, config)
|
|
188
|
-
|
|
189
|
-
console.log(`Packed: ${outputPath}`)
|
|
190
|
-
console.log(`Done. files=${entries.length}, restored=${restored}, deleted=${deleted}`)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
189
|
function omitUndefined(value) {
|
|
194
190
|
return Object.keys(value).reduce((result, key) => {
|
|
195
191
|
if (value[key] !== undefined) {
|