cc-viewer 1.6.18 → 1.6.19
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/dist/assets/{index-_YY_bV5h.css → index-B0PFQOGX.css} +2 -2
- package/dist/assets/{index-7Mvpu6NN.js → index-BU4Xu0xM.js} +78 -78
- package/dist/index.html +2 -2
- package/i18n.js +1 -0
- package/lib/file-api.js +128 -0
- package/lib/log-management.js +173 -0
- package/lib/plugin-manager.js +118 -0
- package/lib/translator.js +84 -0
- package/package.json +1 -1
- package/server.js +63 -394
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>Claude Code Viewer</title>
|
|
7
7
|
<link rel="icon" href="/favicon.ico?v=1">
|
|
8
8
|
<link rel="shortcut icon" href="/favicon.ico?v=1">
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BU4Xu0xM.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B0PFQOGX.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/i18n.js
CHANGED
package/lib/file-api.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File API business logic — extracted from server.js
|
|
3
|
+
* Provides path validation, file read/write with security checks.
|
|
4
|
+
*/
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
import { realpathSync, existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check whether targetPath is contained within the project root directory.
|
|
10
|
+
* Resolves symlinks via realpathSync. Returns false on any error.
|
|
11
|
+
* @param {string} targetPath - absolute path to check
|
|
12
|
+
* @param {string} [root] - project root (defaults to CCV_PROJECT_DIR or cwd)
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isPathContained(targetPath, root) {
|
|
16
|
+
try {
|
|
17
|
+
const resolvedRoot = realpathSync(resolve(root || process.env.CCV_PROJECT_DIR || process.cwd()));
|
|
18
|
+
const real = realpathSync(resolve(targetPath));
|
|
19
|
+
return real === resolvedRoot || real.startsWith(resolvedRoot + '/');
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Custom error with a code property for HTTP status mapping */
|
|
24
|
+
class FileApiError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve and validate a file path. Used by readFileContent and file-raw handler.
|
|
33
|
+
* @param {string} cwd - project working directory
|
|
34
|
+
* @param {string} reqPath - requested path (relative or absolute)
|
|
35
|
+
* @param {boolean} isEditorSession - whether this is an editor session
|
|
36
|
+
* @returns {string} resolved absolute file path
|
|
37
|
+
* @throws {FileApiError} with code 'INVALID_PATH'
|
|
38
|
+
*/
|
|
39
|
+
export function resolveFilePath(cwd, reqPath, isEditorSession) {
|
|
40
|
+
if (!reqPath) {
|
|
41
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
42
|
+
}
|
|
43
|
+
if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
|
|
44
|
+
const resolved = resolve(reqPath.startsWith('/') ? reqPath : join(cwd, reqPath));
|
|
45
|
+
if (!isPathContained(resolved, cwd)) {
|
|
46
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
47
|
+
}
|
|
48
|
+
return resolve(resolved);
|
|
49
|
+
}
|
|
50
|
+
return resolve((isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read file content with size limit and security checks.
|
|
55
|
+
* @param {string} cwd - project working directory
|
|
56
|
+
* @param {string} reqPath - requested path
|
|
57
|
+
* @param {boolean} isEditorSession
|
|
58
|
+
* @returns {{ path: string, content: string, size: number }}
|
|
59
|
+
*/
|
|
60
|
+
export function readFileContent(cwd, reqPath, isEditorSession) {
|
|
61
|
+
if (!reqPath) {
|
|
62
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For non-editor sessions with absolute / ".." paths that are within project dir,
|
|
66
|
+
// return the relative path from project root
|
|
67
|
+
if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
|
|
68
|
+
const resolved = resolve(reqPath);
|
|
69
|
+
if (isPathContained(resolved, cwd)) {
|
|
70
|
+
const root = realpathSync(resolve(cwd));
|
|
71
|
+
const relPath = realpathSync(resolved).slice(root.length + 1);
|
|
72
|
+
const targetFile = realpathSync(resolved);
|
|
73
|
+
return _readAndReturn(targetFile, relPath);
|
|
74
|
+
}
|
|
75
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
|
|
79
|
+
return _readAndReturn(targetFile, reqPath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _readAndReturn(targetFile, displayPath) {
|
|
83
|
+
if (!existsSync(targetFile)) {
|
|
84
|
+
throw new FileApiError('NOT_FOUND', `File not found: ${targetFile}`);
|
|
85
|
+
}
|
|
86
|
+
const stat = statSync(targetFile);
|
|
87
|
+
if (!stat.isFile()) {
|
|
88
|
+
throw new FileApiError('NOT_FILE', 'Not a file');
|
|
89
|
+
}
|
|
90
|
+
if (stat.size > 5 * 1024 * 1024) {
|
|
91
|
+
throw new FileApiError('TOO_LARGE', 'File too large');
|
|
92
|
+
}
|
|
93
|
+
const content = readFileSync(targetFile, 'utf-8');
|
|
94
|
+
return { path: displayPath, content, size: stat.size };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write file content.
|
|
99
|
+
* @param {string} cwd - project working directory
|
|
100
|
+
* @param {string} reqPath - requested path
|
|
101
|
+
* @param {string} content - file content to write
|
|
102
|
+
* @param {boolean} isEditorSession
|
|
103
|
+
* @returns {{ path: string, size: number }}
|
|
104
|
+
*/
|
|
105
|
+
export function writeFileContent(cwd, reqPath, content, isEditorSession) {
|
|
106
|
+
if (!reqPath) {
|
|
107
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
108
|
+
}
|
|
109
|
+
if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
|
|
110
|
+
throw new FileApiError('INVALID_PATH', 'Invalid path');
|
|
111
|
+
}
|
|
112
|
+
if (typeof content !== 'string') {
|
|
113
|
+
throw new FileApiError('INVALID_CONTENT', 'Content must be a string');
|
|
114
|
+
}
|
|
115
|
+
const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
|
|
116
|
+
writeFileSync(targetFile, content, 'utf-8');
|
|
117
|
+
const stat = statSync(targetFile);
|
|
118
|
+
return { path: reqPath, size: stat.size };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Map FileApiError codes to HTTP status codes */
|
|
122
|
+
export const ERROR_STATUS_MAP = {
|
|
123
|
+
INVALID_PATH: 400,
|
|
124
|
+
NOT_FOUND: 404,
|
|
125
|
+
NOT_FILE: 400,
|
|
126
|
+
TOO_LARGE: 413,
|
|
127
|
+
INVALID_CONTENT: 400,
|
|
128
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate that a resolved file path is contained within logDir.
|
|
6
|
+
* Throws on invalid path (not found or path traversal).
|
|
7
|
+
* @param {string} logDir - base log directory
|
|
8
|
+
* @param {string} file - relative file path (e.g. "project/file.jsonl")
|
|
9
|
+
* @returns {string} the real (resolved) path
|
|
10
|
+
*/
|
|
11
|
+
export function validateLogPath(logDir, file) {
|
|
12
|
+
const filePath = join(logDir, file);
|
|
13
|
+
if (!existsSync(filePath)) {
|
|
14
|
+
const err = new Error('File not found');
|
|
15
|
+
err.code = 'NOT_FOUND';
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
const realPath = realpathSync(filePath);
|
|
19
|
+
const realLogDir = realpathSync(logDir);
|
|
20
|
+
if (!realPath.startsWith(realLogDir)) {
|
|
21
|
+
const err = new Error('Access denied');
|
|
22
|
+
err.code = 'ACCESS_DENIED';
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
return realPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* List local log files grouped by project.
|
|
30
|
+
* @param {string} logDir - base log directory
|
|
31
|
+
* @param {string} currentProjectName - current project name (may be empty)
|
|
32
|
+
* @returns {{ [project: string]: Array, _currentProject: string }}
|
|
33
|
+
*/
|
|
34
|
+
export function listLocalLogs(logDir, currentProjectName) {
|
|
35
|
+
const grouped = {};
|
|
36
|
+
if (existsSync(logDir)) {
|
|
37
|
+
const entries = readdirSync(logDir, { withFileTypes: true });
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (!entry.isDirectory()) continue;
|
|
40
|
+
const project = entry.name;
|
|
41
|
+
const projectDir = join(logDir, project);
|
|
42
|
+
const files = readdirSync(projectDir)
|
|
43
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
44
|
+
.sort()
|
|
45
|
+
.reverse();
|
|
46
|
+
// 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
|
|
47
|
+
let statsFiles = null;
|
|
48
|
+
try {
|
|
49
|
+
const statsFile = join(projectDir, `${project}.json`);
|
|
50
|
+
if (existsSync(statsFile)) {
|
|
51
|
+
statsFiles = JSON.parse(readFileSync(statsFile, 'utf-8')).files;
|
|
52
|
+
}
|
|
53
|
+
} catch { }
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl$/);
|
|
56
|
+
if (!match) continue;
|
|
57
|
+
const ts = match[2];
|
|
58
|
+
const filePath = join(projectDir, f);
|
|
59
|
+
const size = statSync(filePath).size;
|
|
60
|
+
if (size === 0) continue; // 跳过空文件
|
|
61
|
+
const turns = statsFiles?.[f]?.summary?.sessionCount || 0;
|
|
62
|
+
if (!grouped[project]) grouped[project] = [];
|
|
63
|
+
grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: statsFiles?.[f]?.preview || [] });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { ...grouped, _currentProject: currentProjectName || '' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read and parse a local log file.
|
|
72
|
+
* @param {string} logDir - base log directory
|
|
73
|
+
* @param {string} file - relative file path (e.g. "project/file.jsonl")
|
|
74
|
+
* @returns {Array<Object>} parsed entries
|
|
75
|
+
*/
|
|
76
|
+
export function readLocalLog(logDir, file) {
|
|
77
|
+
validateLogPath(logDir, file);
|
|
78
|
+
const filePath = join(logDir, file);
|
|
79
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
80
|
+
const entries = content.split('\n---\n').filter(line => line.trim()).map(entry => {
|
|
81
|
+
try { return JSON.parse(entry); } catch { return null; }
|
|
82
|
+
}).filter(Boolean);
|
|
83
|
+
return entries;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Delete log files. Returns per-file results.
|
|
88
|
+
* @param {string} logDir - base log directory
|
|
89
|
+
* @param {string[]} files - array of relative file paths
|
|
90
|
+
* @returns {Array<{ file: string, ok?: boolean, error?: string }>}
|
|
91
|
+
*/
|
|
92
|
+
export function deleteLogFiles(logDir, files) {
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
if (!file || file.includes('..') || !file.endsWith('.jsonl')) {
|
|
96
|
+
results.push({ file, error: 'Invalid file name' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const filePath = join(logDir, file);
|
|
100
|
+
try {
|
|
101
|
+
if (!existsSync(filePath)) {
|
|
102
|
+
results.push({ file, error: 'Not found' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const realPath = realpathSync(filePath);
|
|
106
|
+
const realLogDir = realpathSync(logDir);
|
|
107
|
+
if (!realPath.startsWith(realLogDir)) {
|
|
108
|
+
results.push({ file, error: 'Access denied' });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
unlinkSync(realPath);
|
|
112
|
+
results.push({ file, ok: true });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
results.push({ file, error: err.message });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Merge multiple log files into the first one, deleting the rest.
|
|
122
|
+
* @param {string} logDir - base log directory
|
|
123
|
+
* @param {string[]} files - array of relative file paths (at least 2, same project, chronological order)
|
|
124
|
+
* @returns {string} the merged target file path (relative)
|
|
125
|
+
*/
|
|
126
|
+
export function mergeLogFiles(logDir, files) {
|
|
127
|
+
if (!Array.isArray(files) || files.length < 2) {
|
|
128
|
+
const err = new Error('At least 2 files required');
|
|
129
|
+
err.code = 'INVALID_INPUT';
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
// 校验所有文件属于同一 project
|
|
133
|
+
const projects = new Set(files.map(f => f.split('/')[0]));
|
|
134
|
+
if (projects.size !== 1) {
|
|
135
|
+
const err = new Error('All files must belong to the same project');
|
|
136
|
+
err.code = 'INVALID_INPUT';
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
// 校验文件存在且无路径穿越
|
|
140
|
+
for (const f of files) {
|
|
141
|
+
if (f.includes('..')) {
|
|
142
|
+
const err = new Error('Invalid file path');
|
|
143
|
+
err.code = 'INVALID_INPUT';
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
if (!existsSync(join(logDir, f))) {
|
|
147
|
+
const err = new Error(`File not found: ${f}`);
|
|
148
|
+
err.code = 'NOT_FOUND';
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 校验合并后总大小不超过 300MB
|
|
153
|
+
const MAX_MERGE_SIZE = 300 * 1024 * 1024;
|
|
154
|
+
let totalSize = 0;
|
|
155
|
+
for (const f of files) {
|
|
156
|
+
totalSize += statSync(join(logDir, f)).size;
|
|
157
|
+
}
|
|
158
|
+
if (totalSize > MAX_MERGE_SIZE) {
|
|
159
|
+
const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds 300MB limit`);
|
|
160
|
+
err.code = 'INVALID_INPUT';
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
// 合并内容写入第一个文件
|
|
164
|
+
const targetFile = files[0];
|
|
165
|
+
const targetPath = join(logDir, targetFile);
|
|
166
|
+
const contents = files.map(f => readFileSync(join(logDir, f), 'utf-8').trimEnd());
|
|
167
|
+
writeFileSync(targetPath, contents.join('\n---\n') + '\n');
|
|
168
|
+
// 删除其余文件
|
|
169
|
+
for (let i = 1; i < files.length; i++) {
|
|
170
|
+
unlinkSync(join(logDir, files[i]));
|
|
171
|
+
}
|
|
172
|
+
return targetFile;
|
|
173
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Upload plugin files from a file list.
|
|
8
|
+
* @param {string} pluginsDir - path to plugins directory
|
|
9
|
+
* @param {Array<{name: string, content: string}>} fileList - files to upload
|
|
10
|
+
* @returns {number} number of files written
|
|
11
|
+
* @throws {Error} on validation failure
|
|
12
|
+
*/
|
|
13
|
+
export function uploadPlugins(pluginsDir, fileList) {
|
|
14
|
+
if (!Array.isArray(fileList) || fileList.length === 0) {
|
|
15
|
+
throw Object.assign(new Error('No files provided'), { statusCode: 400 });
|
|
16
|
+
}
|
|
17
|
+
if (!existsSync(pluginsDir)) {
|
|
18
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
let written = 0;
|
|
21
|
+
for (const { name, content } of fileList) {
|
|
22
|
+
if (!name || typeof content !== 'string') continue;
|
|
23
|
+
const filename = name.replace(/.*[/\\]/, '');
|
|
24
|
+
if (!filename.endsWith('.js') && !filename.endsWith('.mjs')) {
|
|
25
|
+
throw Object.assign(new Error('Only .js or .mjs files are allowed'), { statusCode: 400 });
|
|
26
|
+
}
|
|
27
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
28
|
+
throw Object.assign(new Error('Invalid file name'), { statusCode: 400 });
|
|
29
|
+
}
|
|
30
|
+
writeFileSync(join(pluginsDir, filename), content, 'utf-8');
|
|
31
|
+
written++;
|
|
32
|
+
}
|
|
33
|
+
return written;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Install a plugin by downloading from a URL.
|
|
38
|
+
* @param {string} pluginsDir - path to plugins directory
|
|
39
|
+
* @param {string} fileUrl - URL to download from
|
|
40
|
+
* @param {string} extractNameScript - path to lib/extract-plugin-name.mjs
|
|
41
|
+
* @returns {Promise<{filename: string}>} the saved filename
|
|
42
|
+
* @throws {Error} on validation or download failure
|
|
43
|
+
*/
|
|
44
|
+
export async function installPluginFromUrl(pluginsDir, fileUrl, extractNameScript) {
|
|
45
|
+
if (!fileUrl) {
|
|
46
|
+
throw Object.assign(new Error('URL is required'), { statusCode: 400 });
|
|
47
|
+
}
|
|
48
|
+
// Validate URL format
|
|
49
|
+
let parsedUrl;
|
|
50
|
+
try {
|
|
51
|
+
parsedUrl = new URL(fileUrl);
|
|
52
|
+
} catch {
|
|
53
|
+
throw Object.assign(new Error('Invalid URL'), { statusCode: 400 });
|
|
54
|
+
}
|
|
55
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
56
|
+
throw Object.assign(new Error('Invalid URL'), { statusCode: 400 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Download remote file (5MB limit, 30s timeout)
|
|
60
|
+
const MAX_PLUGIN_SIZE = 5 * 1024 * 1024;
|
|
61
|
+
let content;
|
|
62
|
+
try {
|
|
63
|
+
const resp = await fetch(fileUrl, { signal: AbortSignal.timeout(30000) });
|
|
64
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
65
|
+
const text = await resp.text();
|
|
66
|
+
if (text.length > MAX_PLUGIN_SIZE) throw new Error('File too large (max 5MB)');
|
|
67
|
+
content = text;
|
|
68
|
+
} catch (fetchErr) {
|
|
69
|
+
throw Object.assign(new Error('Failed to fetch: ' + fetchErr.message), { statusCode: 500 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Extract plugin name via subprocess import()
|
|
73
|
+
let saveName = '';
|
|
74
|
+
const tmpFile = join(tmpdir(), `ccv-install-${Date.now()}.mjs`);
|
|
75
|
+
writeFileSync(tmpFile, content, 'utf-8');
|
|
76
|
+
try {
|
|
77
|
+
const result = await new Promise((resolve, reject) => {
|
|
78
|
+
execFile('node', [extractNameScript, tmpFile], { timeout: 5000 }, (err, stdout) => {
|
|
79
|
+
if (err) return reject(err);
|
|
80
|
+
resolve(stdout);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
const parsed = JSON.parse(result);
|
|
84
|
+
if (parsed.name) saveName = parsed.name;
|
|
85
|
+
} catch { }
|
|
86
|
+
try { unlinkSync(tmpFile); } catch { }
|
|
87
|
+
|
|
88
|
+
// Fallback: extract filename from URL path, excluding generic names
|
|
89
|
+
if (!saveName) {
|
|
90
|
+
const urlFilename = parsedUrl.pathname.split('/').pop();
|
|
91
|
+
if (urlFilename && (urlFilename.endsWith('.js') || urlFilename.endsWith('.mjs'))
|
|
92
|
+
&& urlFilename !== 'index.js' && urlFilename !== 'index.mjs') {
|
|
93
|
+
saveName = urlFilename.replace(/\.(js|mjs)$/, '');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Final fallback: use plugin-<timestamp>
|
|
97
|
+
if (!saveName) {
|
|
98
|
+
saveName = `plugin-${Date.now()}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let filename = (saveName.endsWith('.js') || saveName.endsWith('.mjs')) ? saveName : saveName + '.js';
|
|
102
|
+
// Safety check
|
|
103
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
104
|
+
filename = `plugin-${Date.now()}.js`;
|
|
105
|
+
}
|
|
106
|
+
// Ensure plugins dir exists
|
|
107
|
+
if (!existsSync(pluginsDir)) {
|
|
108
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
// Deduplicate: append unique identifier for same-name files
|
|
111
|
+
if (existsSync(join(pluginsDir, filename))) {
|
|
112
|
+
const ext = filename.endsWith('.mjs') ? '.mjs' : '.js';
|
|
113
|
+
const base = filename.slice(0, -ext.length);
|
|
114
|
+
filename = `${base}-${Date.now()}${ext}`;
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(join(pluginsDir, filename), content, 'utf-8');
|
|
117
|
+
return { filename };
|
|
118
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { detectLanguage } from '../i18n.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Determine the target language for translation.
|
|
6
|
+
* Priority: explicit `to` param > prefs file lang > system locale.
|
|
7
|
+
* @param {string} prefsFile - Path to the preferences JSON file
|
|
8
|
+
* @returns {string} target language code
|
|
9
|
+
*/
|
|
10
|
+
export function detectTargetLang(prefsFile) {
|
|
11
|
+
let targetLang;
|
|
12
|
+
try {
|
|
13
|
+
if (prefsFile && existsSync(prefsFile)) {
|
|
14
|
+
const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
|
|
15
|
+
if (prefs.lang) targetLang = prefs.lang;
|
|
16
|
+
}
|
|
17
|
+
} catch { }
|
|
18
|
+
if (!targetLang) targetLang = detectLanguage();
|
|
19
|
+
return targetLang;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Translate text using the Claude API.
|
|
24
|
+
* @param {Object} opts
|
|
25
|
+
* @param {string|string[]} opts.text - Text or array of texts to translate
|
|
26
|
+
* @param {string} opts.from - Source language code
|
|
27
|
+
* @param {string} opts.to - Target language code
|
|
28
|
+
* @param {string} opts.apiKey - Anthropic API key
|
|
29
|
+
* @param {string} [opts.baseUrl='https://api.anthropic.com'] - API base URL
|
|
30
|
+
* @param {string} [opts.model='claude-haiku-4-5-20251001'] - Model to use
|
|
31
|
+
* @returns {Promise<{text: string|string[], from: string, to: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function translate({ text, from, to, apiKey, baseUrl, model }) {
|
|
34
|
+
// Same language — no-op
|
|
35
|
+
if (from === to) {
|
|
36
|
+
return { text, from, to };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const effectiveBaseUrl = baseUrl || 'https://api.anthropic.com';
|
|
40
|
+
const effectiveModel = model || 'claude-haiku-4-5-20251001';
|
|
41
|
+
const inputText = Array.isArray(text) ? text.join('\n---SPLIT---\n') : text;
|
|
42
|
+
|
|
43
|
+
const reqHeaders = {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'anthropic-version': '2023-06-01',
|
|
46
|
+
'x-api-key': apiKey,
|
|
47
|
+
'x-cc-viewer-internal': '1',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const apiRes = await fetch(`${effectiveBaseUrl}/v1/messages`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: reqHeaders,
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
model: effectiveModel,
|
|
55
|
+
max_tokens: 32000,
|
|
56
|
+
tools: [],
|
|
57
|
+
system: [{
|
|
58
|
+
type: "text",
|
|
59
|
+
text: `You are a translator. Translate the following text from ${from} to ${to}. Output only the translated text, nothing else.`
|
|
60
|
+
}],
|
|
61
|
+
messages: [{ role: 'user', content: inputText }],
|
|
62
|
+
stream: false,
|
|
63
|
+
temperature: 1,
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!apiRes.ok) {
|
|
68
|
+
const errBody = await apiRes.text();
|
|
69
|
+
const err = new Error(`Translation API failed (status ${apiRes.status}): ${errBody}`);
|
|
70
|
+
err.status = apiRes.status;
|
|
71
|
+
err.detail = errBody;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const apiData = await apiRes.json();
|
|
76
|
+
let translated = apiData.content?.[0]?.text || '';
|
|
77
|
+
|
|
78
|
+
// If input was an array, split the result back into an array
|
|
79
|
+
if (Array.isArray(text)) {
|
|
80
|
+
translated = translated.split(/\n?---SPLIT---\n?/);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { text: translated, from, to };
|
|
84
|
+
}
|