hydrooj-addons-manager 0.1.3 → 1.0.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/README.md +21 -0
- package/index.ts +358 -188
- package/locale/zh_TW.yaml +10 -10
- package/package.json +20 -15
- package/templates/manage_addons.html +142 -143
- package/templates/manage_base.html +0 -24
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
hydrooj-addons-manager Plugin for HydroOJ
|
|
2
|
+
=======================================
|
|
3
|
+
This plugin provides an Addons Manager for HydroOJ, allowing users to easily manage and install addons in web for their HydroOJ instance.
|
|
4
|
+
|
|
5
|
+
Features
|
|
6
|
+
--------
|
|
7
|
+
- List installed addons
|
|
8
|
+
- Add new addons via package name or Git URL
|
|
9
|
+
- Remove existing addons
|
|
10
|
+
- Update addons to the latest version
|
|
11
|
+
|
|
12
|
+
hydrooj-addons-manager 套件
|
|
13
|
+
========================
|
|
14
|
+
這個套件為 HydroOJ 提供了一個套件管理器,允許使用者在網頁介面中輕鬆管理和安裝 HydroOJ 的套件。
|
|
15
|
+
|
|
16
|
+
功能
|
|
17
|
+
--------
|
|
18
|
+
- 列出已安裝的套件
|
|
19
|
+
- 透過套件名稱或 Git URL 新增套件
|
|
20
|
+
- 移除已存在的套件
|
|
21
|
+
- 更新套件到最新版本
|
package/index.ts
CHANGED
|
@@ -1,189 +1,359 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Context, Handler, PRIV, Schema,
|
|
3
|
-
Service, superagent, SystemModel,
|
|
4
|
-
TokenModel,
|
|
5
|
-
UserFacingError, ForbiddenError, Types,
|
|
6
|
-
Model,
|
|
7
|
-
requireSudo
|
|
8
|
-
} from 'hydrooj';
|
|
9
|
-
import {
|
|
10
|
-
import * as fs from 'fs/promises';
|
|
11
|
-
import * as path from 'path';
|
|
12
|
-
import semver from 'semver';
|
|
13
|
-
import validatePackageName from 'validate-npm-package-name';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
1
|
+
import {
|
|
2
|
+
Context, Handler, PRIV, Schema,
|
|
3
|
+
Service, superagent, SystemModel,
|
|
4
|
+
TokenModel,
|
|
5
|
+
UserFacingError, ForbiddenError, Types,
|
|
6
|
+
Model,
|
|
7
|
+
requireSudo
|
|
8
|
+
} from 'hydrooj';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import * as fs from 'fs/promises';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import semver from 'semver';
|
|
13
|
+
import validatePackageName from 'validate-npm-package-name';
|
|
14
|
+
|
|
15
|
+
// Constants
|
|
16
|
+
const ADDON_JSON = 'addon.json';
|
|
17
|
+
const ADDON_LOCKED_JSON = 'addon-locked.json';
|
|
18
|
+
const ADDONS_DIR = 'addons/';
|
|
19
|
+
const TEMPLATE_NAME = 'manage_addons.html';
|
|
20
|
+
const ROUTE_PATH = '/manage/addons';
|
|
21
|
+
const DEFAULT_BRANCH = 'main';
|
|
22
|
+
const LOG_PREFIX = '[Addons Manager]';
|
|
23
|
+
|
|
24
|
+
// Types
|
|
25
|
+
type CommandResult = { success: boolean; message?: string };
|
|
26
|
+
type PackageInfo = { name: string; version: string };
|
|
27
|
+
type PackageAction = 'add' | 'delete' | 'update';
|
|
28
|
+
type PackageOperation = PackageInfo & { action: PackageAction };
|
|
29
|
+
type ManagerResponse = {
|
|
30
|
+
packages: string[];
|
|
31
|
+
lockedPackages: string[];
|
|
32
|
+
success: boolean | null;
|
|
33
|
+
result: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type AddonsManagerModel = {
|
|
37
|
+
manageAddon: (action: PackageAction, name: string, version?: string) => Promise<CommandResult>;
|
|
38
|
+
localUpdate: (name: string) => Promise<CommandResult>;
|
|
39
|
+
localDelete: (name: string) => Promise<CommandResult>;
|
|
40
|
+
localAdd: (name: string) => Promise<CommandResult>;
|
|
41
|
+
getActivedPackages: () => Promise<string[]>;
|
|
42
|
+
getLockedPackages: () => Promise<string[]>;
|
|
43
|
+
localPackageName: (name: string) => string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Validators
|
|
47
|
+
const PackageValidator = {
|
|
48
|
+
checkNpmPackageName(name: string): boolean {
|
|
49
|
+
return validatePackageName(name).validForNewPackages;
|
|
50
|
+
},
|
|
51
|
+
checkNpmVersion(version: string): boolean {
|
|
52
|
+
return version === '' || semver.valid(version) !== null;
|
|
53
|
+
},
|
|
54
|
+
checkPackageIsGitUrl(name: string): boolean {
|
|
55
|
+
return name.endsWith('.git') && (name.startsWith('http://') || name.startsWith('https://') || name.startsWith('git@'));
|
|
56
|
+
},
|
|
57
|
+
checkPackageIsLocal(name: string): boolean {
|
|
58
|
+
return (name[0] === '/' && path.isAbsolute(name)) || this.checkPackageIsGitUrl(name);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
class AddonsManagerHandler extends Handler {
|
|
62
|
+
private static model: AddonsManagerModel;
|
|
63
|
+
|
|
64
|
+
static setModel(m: AddonsManagerModel) {
|
|
65
|
+
this.model = m;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async getManagerResponse(): Promise<ManagerResponse> {
|
|
69
|
+
const packages = await AddonsManagerHandler.model.getActivedPackages();
|
|
70
|
+
const lockedPackages = await AddonsManagerHandler.model.getLockedPackages();
|
|
71
|
+
return {
|
|
72
|
+
packages,
|
|
73
|
+
lockedPackages,
|
|
74
|
+
success: null,
|
|
75
|
+
result: null
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@requireSudo
|
|
80
|
+
async get() {
|
|
81
|
+
this.response.template = TEMPLATE_NAME;
|
|
82
|
+
const response = await this.getManagerResponse();
|
|
83
|
+
this.response.body = this.request.body || response;
|
|
84
|
+
this.renderHTML(this.response.template, { title: 'manage_addons' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async post() {
|
|
88
|
+
const body = this.request.body;
|
|
89
|
+
const pkg: PackageOperation = {
|
|
90
|
+
name: body['package_name'],
|
|
91
|
+
version: body['package_version'] || '',
|
|
92
|
+
action: body['action']
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const result = await this.handlePackageOperation(pkg);
|
|
96
|
+
this.logOperation(pkg, result);
|
|
97
|
+
|
|
98
|
+
if (result.success) {
|
|
99
|
+
const response = await this.getManagerResponse();
|
|
100
|
+
this.response.template = TEMPLATE_NAME;
|
|
101
|
+
this.response.body = {
|
|
102
|
+
...response,
|
|
103
|
+
success: result.success,
|
|
104
|
+
result: result.message || 'Operation successful'
|
|
105
|
+
};
|
|
106
|
+
this.renderHTML(this.response.template, { title: 'manage_addons' });
|
|
107
|
+
} else {
|
|
108
|
+
throw new UserFacingError(result.message || 'Operation failed');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async handlePackageOperation(pkg: PackageOperation): Promise<CommandResult> {
|
|
113
|
+
const packages = await AddonsManagerHandler.model.getActivedPackages();
|
|
114
|
+
const lockedPackages = await AddonsManagerHandler.model.getLockedPackages();
|
|
115
|
+
|
|
116
|
+
const isInstalled = packages.includes(pkg.name) ||
|
|
117
|
+
packages.includes(AddonsManagerHandler.model.localPackageName(pkg.name));
|
|
118
|
+
const isLocked = lockedPackages.includes(pkg.name);
|
|
119
|
+
|
|
120
|
+
return PackageValidator.checkPackageIsLocal(pkg.name)
|
|
121
|
+
? this.handleLocalPackageOperation(pkg, isInstalled, isLocked)
|
|
122
|
+
: this.handleRemotePackageOperation(pkg, isInstalled, isLocked);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async handleLocalPackageOperation(pkg: PackageOperation, isInstalled: boolean, isLocked: boolean): Promise<CommandResult> {
|
|
126
|
+
const localName = AddonsManagerHandler.model.localPackageName(pkg.name);
|
|
127
|
+
|
|
128
|
+
if (localName === '') {
|
|
129
|
+
return { success: false, message: 'Invalid local package path.' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
switch (pkg.action) {
|
|
133
|
+
case 'add':
|
|
134
|
+
return AddonsManagerHandler.model.localAdd(pkg.name);
|
|
135
|
+
case 'update':
|
|
136
|
+
if (isInstalled) {
|
|
137
|
+
return AddonsManagerHandler.model.localUpdate(pkg.name);
|
|
138
|
+
}
|
|
139
|
+
return { success: false, message: 'Package is already installed. Use local update instead.' };
|
|
140
|
+
case 'delete':
|
|
141
|
+
return AddonsManagerHandler.model.localDelete(pkg.name);
|
|
142
|
+
default:
|
|
143
|
+
return { success: false, message: 'Unknown action' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async handleRemotePackageOperation(
|
|
148
|
+
pkg: PackageOperation,
|
|
149
|
+
isInstalled: boolean,
|
|
150
|
+
isLocked: boolean
|
|
151
|
+
): Promise<CommandResult> {
|
|
152
|
+
switch (pkg.action) {
|
|
153
|
+
case 'add':
|
|
154
|
+
if (isInstalled) {
|
|
155
|
+
return { success: false, message: 'Package is already installed' };
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case 'update':
|
|
159
|
+
case 'delete':
|
|
160
|
+
if (!isInstalled) {
|
|
161
|
+
return { success: false, message: 'Package is not installed' };
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (pkg.action === 'delete' && isLocked) {
|
|
167
|
+
return { success: false, message: 'This package is locked and cannot be removed.' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return AddonsManagerHandler.model.manageAddon(pkg.action, pkg.name, pkg.version);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private logOperation(pkg: PackageOperation, result: CommandResult): void {
|
|
174
|
+
console.log(
|
|
175
|
+
`${LOG_PREFIX} Action=${pkg.action} Package=${pkg.name} Version=${pkg.version} Result=${JSON.stringify(result)}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function sendCommand(command: string, args: string[], cwd: string): Promise<CommandResult> {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const child = spawn(command, args, { cwd });
|
|
183
|
+
let stdout = '';
|
|
184
|
+
let stderr = '';
|
|
185
|
+
|
|
186
|
+
child.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
187
|
+
child.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
188
|
+
|
|
189
|
+
child.on('error', (error) => {
|
|
190
|
+
resolve({ success: false, message: error.message });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.on('close', (code) => {
|
|
194
|
+
if (code === 0) {
|
|
195
|
+
resolve({ success: true, message: stdout || stderr });
|
|
196
|
+
} else {
|
|
197
|
+
resolve({
|
|
198
|
+
success: false,
|
|
199
|
+
message: stderr || stdout || `Exit code ${code}`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default class AddonsManagerService extends Service {
|
|
207
|
+
static Config = Schema.object({
|
|
208
|
+
pathToHydro: Schema.string().description('Path to Hydro').required(),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
constructor(ctx: Context, config: ReturnType<typeof AddonsManagerService.Config>) {
|
|
212
|
+
super(ctx, 'hydrooj-addons-manager');
|
|
213
|
+
ctx.Route('manage_addons', ROUTE_PATH, AddonsManagerHandler, PRIV.PRIV_ALL);
|
|
214
|
+
global.Hydro.ui.inject('ControlPanel', 'manage_addons');
|
|
215
|
+
this.initialize(ctx, config);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private initialize(ctx: Context, config: ReturnType<typeof AddonsManagerService.Config>): void {
|
|
219
|
+
const model = this.createModel(config);
|
|
220
|
+
AddonsManagerHandler.setModel(model);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private createModel(config: ReturnType<typeof AddonsManagerService.Config>): AddonsManagerModel {
|
|
224
|
+
return {
|
|
225
|
+
manageAddon: (action, name, version = '') => this.manageAddon(action, name, version, config),
|
|
226
|
+
localUpdate: (name) => this.localUpdate(name, config),
|
|
227
|
+
localDelete: (name) => this.localDelete(name, config),
|
|
228
|
+
localAdd: (name) => this.localAdd(name, config),
|
|
229
|
+
getActivedPackages: () => this.getActivedPackages(config),
|
|
230
|
+
getLockedPackages: () => this.getLockedPackages(config),
|
|
231
|
+
localPackageName: (name) => this.extractLocalPackageName(name, config),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async manageAddon(
|
|
236
|
+
action: PackageAction,
|
|
237
|
+
name: string,
|
|
238
|
+
version: string = '',
|
|
239
|
+
config: ReturnType<typeof AddonsManagerService.Config>
|
|
240
|
+
): Promise<CommandResult> {
|
|
241
|
+
if (!PackageValidator.checkNpmPackageName(name)) {
|
|
242
|
+
return { success: false, message: 'Invalid package name' };
|
|
243
|
+
}
|
|
244
|
+
if (version !== '' && !PackageValidator.checkNpmVersion(version)) {
|
|
245
|
+
return { success: false, message: 'Invalid version' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
switch (action) {
|
|
249
|
+
case 'delete':
|
|
250
|
+
return this.deleteRemotePackage(name, config);
|
|
251
|
+
case 'update':
|
|
252
|
+
return this.updateRemotePackage(name, version, config);
|
|
253
|
+
case 'add':
|
|
254
|
+
return this.addRemotePackage(name, version, config);
|
|
255
|
+
default:
|
|
256
|
+
return { success: false, message: 'Unknown action' };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async deleteRemotePackage(name: string, config: ReturnType<typeof AddonsManagerService.Config>): Promise<CommandResult> {
|
|
261
|
+
const yarnResult = await sendCommand('yarn', ['global', 'remove', name], config.pathToHydro);
|
|
262
|
+
if(!yarnResult.success) return yarnResult;
|
|
263
|
+
const result = await sendCommand('hydrooj', ['addon', 'remove', name], config.pathToHydro);
|
|
264
|
+
return { success: result.success, message: (yarnResult.message || '') + result.message };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async updateRemotePackage(
|
|
268
|
+
name: string,
|
|
269
|
+
version: string,
|
|
270
|
+
config: ReturnType<typeof AddonsManagerService.Config>
|
|
271
|
+
): Promise<CommandResult> {
|
|
272
|
+
const packageSpec = version ? `${name}@${version}` : name;
|
|
273
|
+
return sendCommand('yarn', ['global', 'upgrade', packageSpec, '--latest'], config.pathToHydro);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async addRemotePackage(
|
|
277
|
+
name: string,
|
|
278
|
+
version: string,
|
|
279
|
+
config: ReturnType<typeof AddonsManagerService.Config>
|
|
280
|
+
): Promise<CommandResult> {
|
|
281
|
+
const packageSpec = version ? `${name}@${version}` : name;
|
|
282
|
+
const yarnResult = await sendCommand('yarn', ['global', 'add', packageSpec], config.pathToHydro);
|
|
283
|
+
if(!yarnResult.success) return yarnResult;
|
|
284
|
+
const result = await sendCommand('hydrooj', ['addon', 'add', name], config.pathToHydro);
|
|
285
|
+
return { success: result.success, message: (yarnResult.message || '') + result.message };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async localUpdate(name: string, config: ReturnType<typeof AddonsManagerService.Config>): Promise<CommandResult> {
|
|
289
|
+
if (!PackageValidator.checkPackageIsLocal(name)) {
|
|
290
|
+
return { success: false, message: 'Not a local package' };
|
|
291
|
+
}
|
|
292
|
+
const localPath = this.localPackagePath(name, config);
|
|
293
|
+
return sendCommand('git', ['pull', 'origin', DEFAULT_BRANCH], localPath);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async localAdd(name: string, config: ReturnType<typeof AddonsManagerService.Config>): Promise<CommandResult> {
|
|
297
|
+
if (!PackageValidator.checkPackageIsLocal(name)) {
|
|
298
|
+
return { success: false, message: 'Not a local package' };
|
|
299
|
+
}
|
|
300
|
+
const deleteResult = await this.localDelete(name, config);
|
|
301
|
+
const gitResult = await sendCommand('git', ['clone', name], config.pathToHydro + ADDONS_DIR);
|
|
302
|
+
if (!gitResult.success) {
|
|
303
|
+
return gitResult;
|
|
304
|
+
}
|
|
305
|
+
const localPath = this.localPackagePath(name, config);
|
|
306
|
+
const result = await sendCommand('hydrooj', ['addon', 'add', localPath], config.pathToHydro);
|
|
307
|
+
return { success: result.success, message: (gitResult.message || '') + result.message };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async localDelete(name: string, config: ReturnType<typeof AddonsManagerService.Config>): Promise<CommandResult> {
|
|
311
|
+
if (!PackageValidator.checkPackageIsLocal(name)) {
|
|
312
|
+
return { success: false, message: 'Not a local package' };
|
|
313
|
+
}
|
|
314
|
+
const localPath = this.localPackagePath(name, config);
|
|
315
|
+
const deleteResult : CommandResult = await fs.rm(localPath, { recursive: true, force: true })
|
|
316
|
+
.then(() => ({ success: true, message: 'Local package deleted successfully' }))
|
|
317
|
+
.catch((err) => ({ success: false, message: err.message }));
|
|
318
|
+
if (!deleteResult.success) {
|
|
319
|
+
return deleteResult;
|
|
320
|
+
}
|
|
321
|
+
const result = await sendCommand('hydrooj', ['addon', 'remove', localPath], config.pathToHydro);
|
|
322
|
+
return { success: result.success, message: (deleteResult.message || '') + result.message };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async getActivedPackages(config: ReturnType<typeof AddonsManagerService.Config>): Promise<string[]> {
|
|
326
|
+
try {
|
|
327
|
+
const data = await fs.readFile(config.pathToHydro + ADDON_JSON, 'utf-8');
|
|
328
|
+
return JSON.parse(data);
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async getLockedPackages(config: ReturnType<typeof AddonsManagerService.Config>): Promise<string[]> {
|
|
335
|
+
try {
|
|
336
|
+
const data = await fs.readFile(config.pathToHydro + ADDON_LOCKED_JSON, 'utf-8');
|
|
337
|
+
return JSON.parse(data);
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private extractLocalPackageName(name: string, config: ReturnType<typeof AddonsManagerService.Config>): string {
|
|
344
|
+
if (!PackageValidator.checkPackageIsLocal(name)) return '';
|
|
345
|
+
if(PackageValidator.checkPackageIsGitUrl(name)) {
|
|
346
|
+
const match = name.match(/([^/]+?)(\.git)?$/);
|
|
347
|
+
return match ? match[1] : '';
|
|
348
|
+
}
|
|
349
|
+
if(!name.startsWith(config.pathToHydro + ADDONS_DIR)) {
|
|
350
|
+
return '';
|
|
351
|
+
}
|
|
352
|
+
name = name.replace(config.pathToHydro + ADDONS_DIR, '');
|
|
353
|
+
return name ? name : '';
|
|
354
|
+
}
|
|
355
|
+
private localPackagePath(name: string, config: ReturnType<typeof AddonsManagerService.Config>): string {
|
|
356
|
+
const localName = this.extractLocalPackageName(name, config);
|
|
357
|
+
return path.join(config.pathToHydro, ADDONS_DIR, localName);
|
|
358
|
+
}
|
|
189
359
|
}
|
package/locale/zh_TW.yaml
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
__langname: 正體中文
|
|
2
|
-
addons manager: 插件管理員
|
|
3
|
-
activated package name: 已啟用套件名稱
|
|
4
|
-
activate: 啟用
|
|
5
|
-
add new package: 新增套件
|
|
6
|
-
delete: 刪除
|
|
7
|
-
package name:
|
|
8
|
-
update: 更新
|
|
9
|
-
version name *unnecessary: 版本名稱 *非必要
|
|
10
|
-
need to restart hydrooj to make changes work: 需要重新啟動 HydroOJ 以使更改生效
|
|
1
|
+
__langname: 正體中文
|
|
2
|
+
addons manager: 插件管理員
|
|
3
|
+
activated package name: 已啟用套件名稱
|
|
4
|
+
activate: 啟用
|
|
5
|
+
add new package: 新增套件
|
|
6
|
+
delete: 刪除
|
|
7
|
+
package name or package git url: 套件名稱或套件 Git URL
|
|
8
|
+
update: 更新
|
|
9
|
+
version name *unnecessary: 版本名稱 *非必要
|
|
10
|
+
need to restart hydrooj to make changes work: 需要重新啟動 HydroOJ 以使更改生效
|
|
11
11
|
manage_addons: 管理插件
|
package/package.json
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "hydrooj-addons-manager",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "addons manager for hydrooj",
|
|
5
|
-
"main": "index.ts",
|
|
6
|
-
"author": "https://github.com/Bryan0324",
|
|
7
|
-
"dependencies": {
|
|
8
|
-
"semver": "^7.6.0",
|
|
9
|
-
"validate-npm-package-name": "^5.0.0"
|
|
10
|
-
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"@types/semver": "^7.7.1",
|
|
13
|
-
"@types/validate-npm-package-name": "^4.0.2"
|
|
14
|
-
}
|
|
15
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "hydrooj-addons-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "addons manager for hydrooj",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"author": "https://github.com/Bryan0324",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"semver": "^7.6.0",
|
|
9
|
+
"validate-npm-package-name": "^5.0.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/semver": "^7.7.1",
|
|
13
|
+
"@types/validate-npm-package-name": "^4.0.2"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Bryan0324/npm-packages.git",
|
|
18
|
+
"directory": "hydro-plugins/hydrooj-addons-manager"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,144 +1,143 @@
|
|
|
1
|
-
{% extends "manage_base.html" %}
|
|
2
|
-
{% block manage_content %}
|
|
3
|
-
<div class="main">
|
|
4
|
-
<style>
|
|
5
|
-
.manage-addons .section__header { display: flex; flex-direction: column; gap: 12px; }
|
|
6
|
-
.manage-addons .section__title { margin: 0; font-size: 22px; }
|
|
7
|
-
.manage-addons .section__subtitle { margin: 8px 0 4px; font-size: 16px; color: #374151; }
|
|
8
|
-
|
|
9
|
-
.manage-addons #activate-package-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
|
10
|
-
.manage-addons .activate-package-form { margin: 0; }
|
|
11
|
-
.manage-addons .activate-package-item {
|
|
12
|
-
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
|
13
|
-
padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff;
|
|
14
|
-
}
|
|
15
|
-
.manage-addons .activate-package-item a { font-weight: 600; color: #111827; }
|
|
16
|
-
.manage-addons .activate-package-item:hover { box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
|
17
|
-
|
|
18
|
-
.manage-addons .activate-package-item .btn { min-width: 96px; }
|
|
19
|
-
.manage-addons .activate-package-item .btn + .btn { margin-left: 8px; }
|
|
20
|
-
|
|
21
|
-
.manage-addons .activate-package-item input[type="text"] {
|
|
22
|
-
flex: 1; min-width: 160px; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 6px;
|
|
23
|
-
}
|
|
24
|
-
.manage-addons .alert { margin: 6px 0 2px; }
|
|
25
|
-
|
|
26
|
-
/* Styled buttons */
|
|
27
|
-
.manage-addons .btn {
|
|
28
|
-
display: inline-flex;
|
|
29
|
-
align-items: center;
|
|
30
|
-
justify-content: center;
|
|
31
|
-
gap: 6px;
|
|
32
|
-
padding: 8px 12px;
|
|
33
|
-
border-radius: 8px;
|
|
34
|
-
font-weight: 600;
|
|
35
|
-
font-size: 14px;
|
|
36
|
-
line-height: 1.2;
|
|
37
|
-
border: 1px solid transparent;
|
|
38
|
-
cursor: pointer;
|
|
39
|
-
transition: background-color .15s ease, color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .05s ease;
|
|
40
|
-
user-select: none;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.manage-addons .btn:disabled {
|
|
44
|
-
opacity: .6;
|
|
45
|
-
cursor: not-allowed;
|
|
46
|
-
transform: none !important;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/* Primary */
|
|
50
|
-
.manage-addons .btn--primary {
|
|
51
|
-
background: #2563eb;
|
|
52
|
-
color: #fff;
|
|
53
|
-
border-color: #1d4ed8;
|
|
54
|
-
}
|
|
55
|
-
.manage-addons .btn--primary:hover { background: #1d4ed8; }
|
|
56
|
-
.manage-addons .btn--primary:active { transform: translateY(1px); }
|
|
57
|
-
.manage-addons .btn--primary:focus-visible {
|
|
58
|
-
outline: none;
|
|
59
|
-
box-shadow: 0 0 0 3px rgba(37,99,235,.25);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/* Secondary */
|
|
63
|
-
.manage-addons .btn--secondary {
|
|
64
|
-
background: #ffffff;
|
|
65
|
-
color: #111827;
|
|
66
|
-
border-color: #d1d5db;
|
|
67
|
-
}
|
|
68
|
-
.manage-addons .btn--secondary:hover { background: #f3f4f6; }
|
|
69
|
-
.manage-addons .btn--secondary:active { transform: translateY(1px); }
|
|
70
|
-
.manage-addons .btn--secondary:focus-visible {
|
|
71
|
-
outline: none;
|
|
72
|
-
box-shadow: 0 0 0 3px rgba(17,24,39,.15);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/* Danger */
|
|
76
|
-
.manage-addons .btn--danger {
|
|
77
|
-
background: #dc2626;
|
|
78
|
-
color: #fff;
|
|
79
|
-
border-color: #b91c1c;
|
|
80
|
-
}
|
|
81
|
-
.manage-addons .btn--danger:hover { background: #b91c1c; }
|
|
82
|
-
.manage-addons .btn--danger:active { transform: translateY(1px); }
|
|
83
|
-
.manage-addons .btn--danger:focus-visible {
|
|
84
|
-
outline: none;
|
|
85
|
-
box-shadow: 0 0 0 3px rgba(220,38,38,.25);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/* Dark mode button tweaks */
|
|
89
|
-
@media (prefers-color-scheme: dark) {
|
|
90
|
-
.manage-addons .btn--secondary {
|
|
91
|
-
background: transparent;
|
|
92
|
-
color: #e5e7eb;
|
|
93
|
-
border-color: #374151;
|
|
94
|
-
}
|
|
95
|
-
.manage-addons .btn--secondary:hover { background: #111827; }
|
|
96
|
-
.manage-addons .btn--primary { border-color: #1e40af; }
|
|
97
|
-
.manage-addons .btn--danger { border-color: #991b1b; }
|
|
98
|
-
}
|
|
99
|
-
</style>
|
|
100
|
-
|
|
101
|
-
<div class="section manage-addons">
|
|
102
|
-
<div class="section__header">
|
|
103
|
-
<h1 class="section__title" data-heading>{{ _('addons manager') }}</h1>
|
|
104
|
-
<h2 class="section__subtitle" data-heading>{{ _('activated package name') }}</h2>
|
|
105
|
-
<h2 class="section__subtitle" data-heading style="color: #ef4444; font-weight: 600;">{{ _('need to restart hydrooj to make changes work') }}</h2>
|
|
106
|
-
<ul id="activate-package-list">
|
|
107
|
-
{% for package in packages %}
|
|
108
|
-
<li>
|
|
109
|
-
<form method="post" class="activate-package-form">
|
|
110
|
-
<div class="activate-package-item">
|
|
111
|
-
<a>{{ package }}</a>
|
|
112
|
-
<input type="text" name="package_name" value="{{ package }}" hidden >
|
|
113
|
-
{% if package not in lockedPackages %}
|
|
114
|
-
<button type="submit" name="action" value="delete" class="btn btn--danger">{{ _('delete') }}</button>
|
|
115
|
-
{% endif %}
|
|
116
|
-
<button type="submit" name="action" value="update" class="btn btn--secondary">{{ _('update') }}</button>
|
|
117
|
-
</div>
|
|
118
|
-
</form>
|
|
119
|
-
</li>
|
|
120
|
-
{% endfor %}
|
|
121
|
-
</ul>
|
|
122
|
-
<h2 class="section__subtitle" data-heading>{{ _('add new package') }}</h2>
|
|
123
|
-
<form method="post" class="activate-package-form">
|
|
124
|
-
<div class="activate-package-item">
|
|
125
|
-
<input type="text" name="package_name" placeholder="{{ _('package name') }}" required>
|
|
126
|
-
<input type="text" name="package_version" placeholder="{{ _('version name *unnecessary') }}">
|
|
127
|
-
<button type="submit" name="action" value="add" class="btn btn--primary">{{ _('activate') }}</button>
|
|
128
|
-
</div>
|
|
129
|
-
</form>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
</div>
|
|
1
|
+
{% extends "manage_base.html" %}
|
|
2
|
+
{% block manage_content %}
|
|
3
|
+
<div class="main">
|
|
4
|
+
<style>
|
|
5
|
+
.manage-addons .section__header { display: flex; flex-direction: column; gap: 12px; }
|
|
6
|
+
.manage-addons .section__title { margin: 0; font-size: 22px; }
|
|
7
|
+
.manage-addons .section__subtitle { margin: 8px 0 4px; font-size: 16px; color: #374151; }
|
|
8
|
+
|
|
9
|
+
.manage-addons #activate-package-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
|
10
|
+
.manage-addons .activate-package-form { margin: 0; }
|
|
11
|
+
.manage-addons .activate-package-item {
|
|
12
|
+
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
|
13
|
+
padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff;
|
|
14
|
+
}
|
|
15
|
+
.manage-addons .activate-package-item a { font-weight: 600; color: #111827; }
|
|
16
|
+
.manage-addons .activate-package-item:hover { box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
|
17
|
+
|
|
18
|
+
.manage-addons .activate-package-item .btn { min-width: 96px; }
|
|
19
|
+
.manage-addons .activate-package-item .btn + .btn { margin-left: 8px; }
|
|
20
|
+
|
|
21
|
+
.manage-addons .activate-package-item input[type="text"] {
|
|
22
|
+
flex: 1; min-width: 160px; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 6px;
|
|
23
|
+
}
|
|
24
|
+
.manage-addons .alert { margin: 6px 0 2px; }
|
|
25
|
+
|
|
26
|
+
/* Styled buttons */
|
|
27
|
+
.manage-addons .btn {
|
|
28
|
+
display: inline-flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
gap: 6px;
|
|
32
|
+
padding: 8px 12px;
|
|
33
|
+
border-radius: 8px;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
font-size: 14px;
|
|
36
|
+
line-height: 1.2;
|
|
37
|
+
border: 1px solid transparent;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
transition: background-color .15s ease, color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .05s ease;
|
|
40
|
+
user-select: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.manage-addons .btn:disabled {
|
|
44
|
+
opacity: .6;
|
|
45
|
+
cursor: not-allowed;
|
|
46
|
+
transform: none !important;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Primary */
|
|
50
|
+
.manage-addons .btn--primary {
|
|
51
|
+
background: #2563eb;
|
|
52
|
+
color: #fff;
|
|
53
|
+
border-color: #1d4ed8;
|
|
54
|
+
}
|
|
55
|
+
.manage-addons .btn--primary:hover { background: #1d4ed8; }
|
|
56
|
+
.manage-addons .btn--primary:active { transform: translateY(1px); }
|
|
57
|
+
.manage-addons .btn--primary:focus-visible {
|
|
58
|
+
outline: none;
|
|
59
|
+
box-shadow: 0 0 0 3px rgba(37,99,235,.25);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Secondary */
|
|
63
|
+
.manage-addons .btn--secondary {
|
|
64
|
+
background: #ffffff;
|
|
65
|
+
color: #111827;
|
|
66
|
+
border-color: #d1d5db;
|
|
67
|
+
}
|
|
68
|
+
.manage-addons .btn--secondary:hover { background: #f3f4f6; }
|
|
69
|
+
.manage-addons .btn--secondary:active { transform: translateY(1px); }
|
|
70
|
+
.manage-addons .btn--secondary:focus-visible {
|
|
71
|
+
outline: none;
|
|
72
|
+
box-shadow: 0 0 0 3px rgba(17,24,39,.15);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Danger */
|
|
76
|
+
.manage-addons .btn--danger {
|
|
77
|
+
background: #dc2626;
|
|
78
|
+
color: #fff;
|
|
79
|
+
border-color: #b91c1c;
|
|
80
|
+
}
|
|
81
|
+
.manage-addons .btn--danger:hover { background: #b91c1c; }
|
|
82
|
+
.manage-addons .btn--danger:active { transform: translateY(1px); }
|
|
83
|
+
.manage-addons .btn--danger:focus-visible {
|
|
84
|
+
outline: none;
|
|
85
|
+
box-shadow: 0 0 0 3px rgba(220,38,38,.25);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Dark mode button tweaks */
|
|
89
|
+
@media (prefers-color-scheme: dark) {
|
|
90
|
+
.manage-addons .btn--secondary {
|
|
91
|
+
background: transparent;
|
|
92
|
+
color: #e5e7eb;
|
|
93
|
+
border-color: #374151;
|
|
94
|
+
}
|
|
95
|
+
.manage-addons .btn--secondary:hover { background: #111827; }
|
|
96
|
+
.manage-addons .btn--primary { border-color: #1e40af; }
|
|
97
|
+
.manage-addons .btn--danger { border-color: #991b1b; }
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
100
|
+
|
|
101
|
+
<div class="section manage-addons">
|
|
102
|
+
<div class="section__header">
|
|
103
|
+
<h1 class="section__title" data-heading>{{ _('addons manager') }}</h1>
|
|
104
|
+
<h2 class="section__subtitle" data-heading>{{ _('activated package name') }}</h2>
|
|
105
|
+
<h2 class="section__subtitle" data-heading style="color: #ef4444; font-weight: 600;">{{ _('need to restart hydrooj to make changes work') }}</h2>
|
|
106
|
+
<ul id="activate-package-list">
|
|
107
|
+
{% for package in packages %}
|
|
108
|
+
<li>
|
|
109
|
+
<form method="post" class="activate-package-form">
|
|
110
|
+
<div class="activate-package-item">
|
|
111
|
+
<a>{{ package }}</a>
|
|
112
|
+
<input type="text" name="package_name" value="{{ package }}" hidden >
|
|
113
|
+
{% if package not in lockedPackages %}
|
|
114
|
+
<button type="submit" name="action" value="delete" class="btn btn--danger">{{ _('delete') }}</button>
|
|
115
|
+
{% endif %}
|
|
116
|
+
<button type="submit" name="action" value="update" class="btn btn--secondary">{{ _('update') }}</button>
|
|
117
|
+
</div>
|
|
118
|
+
</form>
|
|
119
|
+
</li>
|
|
120
|
+
{% endfor %}
|
|
121
|
+
</ul>
|
|
122
|
+
<h2 class="section__subtitle" data-heading>{{ _('add new package') }}</h2>
|
|
123
|
+
<form method="post" class="activate-package-form">
|
|
124
|
+
<div class="activate-package-item">
|
|
125
|
+
<input type="text" name="package_name" placeholder="{{ _('package name or package git url') }}" required>
|
|
126
|
+
<input type="text" name="package_version" placeholder="{{ _('version name *unnecessary') }}">
|
|
127
|
+
<button type="submit" name="action" value="add" class="btn btn--primary">{{ _('activate') }}</button>
|
|
128
|
+
</div>
|
|
129
|
+
</form>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<script>
|
|
134
|
+
const result = {{ result | dump | safe }};
|
|
135
|
+
console.log('Debug: ', result);
|
|
136
|
+
</script>
|
|
137
|
+
{% if success and result %}
|
|
138
|
+
<script>
|
|
139
|
+
const message = {{ result | dump | safe }};
|
|
140
|
+
alert('✓ success: ' + message);
|
|
141
|
+
</script>
|
|
142
|
+
{% endif %}
|
|
144
143
|
{% endblock %}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{% extends "layout/basic.html" %}
|
|
2
|
-
{% block content %}
|
|
3
|
-
<div class="row" data-sticky-parent>
|
|
4
|
-
<div class="medium-9 columns">
|
|
5
|
-
{% block manage_content %}{% endblock %}
|
|
6
|
-
</div>
|
|
7
|
-
<div class="medium-3 columns"><div data-sticky="large">
|
|
8
|
-
<div class="section side">
|
|
9
|
-
<ol class="menu">
|
|
10
|
-
<li class="menu__item">
|
|
11
|
-
<div class="menu__link expandable">
|
|
12
|
-
<span class="icon icon-info"></span> {{ _('Properties') }}
|
|
13
|
-
</div>
|
|
14
|
-
<ol class="menu collapsed">
|
|
15
|
-
{%- for item in ui.getNodes('ControlPanel') -%}
|
|
16
|
-
{{ sidemenu.render_item(item.icon, item.name) }}
|
|
17
|
-
{%- endfor -%}
|
|
18
|
-
</ol>
|
|
19
|
-
</li>
|
|
20
|
-
</ol>
|
|
21
|
-
</div>
|
|
22
|
-
</div></div>
|
|
23
|
-
</div>
|
|
24
|
-
{% endblock %}
|