@ashdev/codex-plugin-release-tsundoku 1.36.1
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 +146 -0
- package/dist/index.js +1161 -0
- package/dist/index.js.map +7 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../sdk-typescript/dist/types/rpc.js
|
|
4
|
+
var JSON_RPC_ERROR_CODES = {
|
|
5
|
+
/** Invalid JSON was received */
|
|
6
|
+
PARSE_ERROR: -32700,
|
|
7
|
+
/** The JSON sent is not a valid Request object */
|
|
8
|
+
INVALID_REQUEST: -32600,
|
|
9
|
+
/** The method does not exist / is not available */
|
|
10
|
+
METHOD_NOT_FOUND: -32601,
|
|
11
|
+
/** Invalid method parameter(s) */
|
|
12
|
+
INVALID_PARAMS: -32602,
|
|
13
|
+
/** Internal JSON-RPC error */
|
|
14
|
+
INTERNAL_ERROR: -32603
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ../sdk-typescript/dist/errors.js
|
|
18
|
+
var PluginError = class extends Error {
|
|
19
|
+
data;
|
|
20
|
+
constructor(message, data) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = this.constructor.name;
|
|
23
|
+
this.data = data;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Convert to JSON-RPC error format
|
|
27
|
+
*/
|
|
28
|
+
toJsonRpcError() {
|
|
29
|
+
return {
|
|
30
|
+
code: this.code,
|
|
31
|
+
message: this.message,
|
|
32
|
+
data: this.data
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ../sdk-typescript/dist/request-context.js
|
|
38
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
39
|
+
var store = new AsyncLocalStorage();
|
|
40
|
+
function runWithParentRequestId(forwardRequestId, fn) {
|
|
41
|
+
return store.run(forwardRequestId, fn);
|
|
42
|
+
}
|
|
43
|
+
function currentParentRequestId() {
|
|
44
|
+
return store.getStore();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ../sdk-typescript/dist/host-rpc.js
|
|
48
|
+
var HostRpcError = class extends Error {
|
|
49
|
+
code;
|
|
50
|
+
data;
|
|
51
|
+
constructor(message, code, data) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.data = data;
|
|
55
|
+
this.name = "HostRpcError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var HostRpcClient = class {
|
|
59
|
+
// Start the counter high so it can't collide with PluginStorage's id space.
|
|
60
|
+
// `Number.MAX_SAFE_INTEGER` is far above this, so we have plenty of room
|
|
61
|
+
// before wrapping (and we never expect a single plugin lifetime to issue
|
|
62
|
+
// more than ~9 quintillion calls).
|
|
63
|
+
nextId = 1e9;
|
|
64
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
65
|
+
writeFn;
|
|
66
|
+
/**
|
|
67
|
+
* @param writeFn - Optional custom write function (defaults to
|
|
68
|
+
* `process.stdout.write`). Useful for testing.
|
|
69
|
+
*/
|
|
70
|
+
constructor(writeFn) {
|
|
71
|
+
this.writeFn = writeFn ?? ((line) => {
|
|
72
|
+
process.stdout.write(line);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Send a JSON-RPC request to the host and resolve with the result.
|
|
77
|
+
*
|
|
78
|
+
* @param method - JSON-RPC method name (e.g. `"releases/list_tracked"`).
|
|
79
|
+
* @param params - Method-specific params. Pass `undefined` when the method
|
|
80
|
+
* takes no params.
|
|
81
|
+
*/
|
|
82
|
+
async call(method, params) {
|
|
83
|
+
const id = this.nextId++;
|
|
84
|
+
const parent = currentParentRequestId();
|
|
85
|
+
const request = {
|
|
86
|
+
jsonrpc: "2.0",
|
|
87
|
+
id,
|
|
88
|
+
method,
|
|
89
|
+
params,
|
|
90
|
+
...parent !== void 0 ? { parentRequestId: parent } : {}
|
|
91
|
+
};
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
this.pendingRequests.set(id, {
|
|
94
|
+
resolve: (v) => resolve(v),
|
|
95
|
+
reject
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
this.writeFn(`${JSON.stringify(request)}
|
|
99
|
+
`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.pendingRequests.delete(id);
|
|
102
|
+
const message = err instanceof Error ? err.message : "Unknown write error";
|
|
103
|
+
reject(new HostRpcError(`Failed to send request: ${message}`, -1));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Process an incoming JSON-RPC response line. Returns `true` if this
|
|
109
|
+
* client owned the response id and resolved it, `false` otherwise (so
|
|
110
|
+
* other clients can try).
|
|
111
|
+
*
|
|
112
|
+
* Called by the plugin server's main loop on every response.
|
|
113
|
+
*/
|
|
114
|
+
handleResponse(line) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed)
|
|
117
|
+
return false;
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(trimmed);
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const obj = parsed;
|
|
125
|
+
if (obj.method !== void 0)
|
|
126
|
+
return false;
|
|
127
|
+
const rawId = obj.id;
|
|
128
|
+
if (typeof rawId !== "number")
|
|
129
|
+
return false;
|
|
130
|
+
if (!this.pendingRequests.has(rawId))
|
|
131
|
+
return false;
|
|
132
|
+
const pending = this.pendingRequests.get(rawId);
|
|
133
|
+
if (!pending)
|
|
134
|
+
return false;
|
|
135
|
+
this.pendingRequests.delete(rawId);
|
|
136
|
+
if ("error" in obj && obj.error) {
|
|
137
|
+
const err = obj.error;
|
|
138
|
+
pending.reject(new HostRpcError(err.message, err.code, err.data));
|
|
139
|
+
} else {
|
|
140
|
+
pending.resolve(obj.result);
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/** Reject all pending requests (e.g. on shutdown). */
|
|
145
|
+
cancelAll() {
|
|
146
|
+
for (const [, pending] of this.pendingRequests) {
|
|
147
|
+
pending.reject(new HostRpcError("Host RPC client stopped", -1));
|
|
148
|
+
}
|
|
149
|
+
this.pendingRequests.clear();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ../sdk-typescript/dist/logger.js
|
|
154
|
+
var LOG_LEVELS = {
|
|
155
|
+
debug: 0,
|
|
156
|
+
info: 1,
|
|
157
|
+
warn: 2,
|
|
158
|
+
error: 3
|
|
159
|
+
};
|
|
160
|
+
var Logger = class {
|
|
161
|
+
name;
|
|
162
|
+
minLevel;
|
|
163
|
+
timestamps;
|
|
164
|
+
constructor(options) {
|
|
165
|
+
this.name = options.name;
|
|
166
|
+
this.minLevel = LOG_LEVELS[options.level ?? "info"];
|
|
167
|
+
this.timestamps = options.timestamps ?? true;
|
|
168
|
+
}
|
|
169
|
+
shouldLog(level) {
|
|
170
|
+
return LOG_LEVELS[level] >= this.minLevel;
|
|
171
|
+
}
|
|
172
|
+
format(level, message, data) {
|
|
173
|
+
const parts = [];
|
|
174
|
+
if (this.timestamps) {
|
|
175
|
+
parts.push((/* @__PURE__ */ new Date()).toISOString());
|
|
176
|
+
}
|
|
177
|
+
parts.push(`[${level.toUpperCase()}]`);
|
|
178
|
+
parts.push(`[${this.name}]`);
|
|
179
|
+
parts.push(message);
|
|
180
|
+
if (data !== void 0) {
|
|
181
|
+
if (data instanceof Error) {
|
|
182
|
+
parts.push(`- ${data.message}`);
|
|
183
|
+
if (data.stack) {
|
|
184
|
+
parts.push(`
|
|
185
|
+
${data.stack}`);
|
|
186
|
+
}
|
|
187
|
+
} else if (typeof data === "object") {
|
|
188
|
+
parts.push(`- ${JSON.stringify(data)}`);
|
|
189
|
+
} else {
|
|
190
|
+
parts.push(`- ${String(data)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return parts.join(" ");
|
|
194
|
+
}
|
|
195
|
+
log(level, message, data) {
|
|
196
|
+
if (this.shouldLog(level)) {
|
|
197
|
+
process.stderr.write(`${this.format(level, message, data)}
|
|
198
|
+
`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
debug(message, data) {
|
|
202
|
+
this.log("debug", message, data);
|
|
203
|
+
}
|
|
204
|
+
info(message, data) {
|
|
205
|
+
this.log("info", message, data);
|
|
206
|
+
}
|
|
207
|
+
warn(message, data) {
|
|
208
|
+
this.log("warn", message, data);
|
|
209
|
+
}
|
|
210
|
+
error(message, data) {
|
|
211
|
+
this.log("error", message, data);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function createLogger(options) {
|
|
215
|
+
return new Logger(options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ../sdk-typescript/dist/server.js
|
|
219
|
+
import { createInterface } from "node:readline";
|
|
220
|
+
|
|
221
|
+
// ../sdk-typescript/dist/storage.js
|
|
222
|
+
var StorageError = class extends Error {
|
|
223
|
+
code;
|
|
224
|
+
data;
|
|
225
|
+
constructor(message, code, data) {
|
|
226
|
+
super(message);
|
|
227
|
+
this.code = code;
|
|
228
|
+
this.data = data;
|
|
229
|
+
this.name = "StorageError";
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
var PluginStorage = class {
|
|
233
|
+
nextId = 1;
|
|
234
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
235
|
+
writeFn;
|
|
236
|
+
/**
|
|
237
|
+
* Create a new storage client.
|
|
238
|
+
*
|
|
239
|
+
* @param writeFn - Optional custom write function (defaults to process.stdout.write).
|
|
240
|
+
* Useful for testing or custom transport layers.
|
|
241
|
+
*/
|
|
242
|
+
constructor(writeFn) {
|
|
243
|
+
this.writeFn = writeFn ?? ((line) => {
|
|
244
|
+
process.stdout.write(line);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Get a value by key
|
|
249
|
+
*
|
|
250
|
+
* @param key - Storage key to retrieve
|
|
251
|
+
* @returns The stored data and optional expiration, or null data if key doesn't exist
|
|
252
|
+
*/
|
|
253
|
+
async get(key) {
|
|
254
|
+
return await this.sendRequest("storage/get", { key });
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Set a value by key (upsert - creates or updates)
|
|
258
|
+
*
|
|
259
|
+
* @param key - Storage key
|
|
260
|
+
* @param data - JSON-serializable data to store
|
|
261
|
+
* @param expiresAt - Optional expiration timestamp (ISO 8601)
|
|
262
|
+
* @returns Success indicator
|
|
263
|
+
*/
|
|
264
|
+
async set(key, data, expiresAt) {
|
|
265
|
+
const params = { key, data };
|
|
266
|
+
if (expiresAt !== void 0) {
|
|
267
|
+
params.expiresAt = expiresAt;
|
|
268
|
+
}
|
|
269
|
+
return await this.sendRequest("storage/set", params);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Delete a value by key
|
|
273
|
+
*
|
|
274
|
+
* @param key - Storage key to delete
|
|
275
|
+
* @returns Whether the key existed and was deleted
|
|
276
|
+
*/
|
|
277
|
+
async delete(key) {
|
|
278
|
+
return await this.sendRequest("storage/delete", { key });
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* List all keys for this plugin instance (excluding expired)
|
|
282
|
+
*
|
|
283
|
+
* @returns List of key entries with metadata
|
|
284
|
+
*/
|
|
285
|
+
async list() {
|
|
286
|
+
return await this.sendRequest("storage/list", {});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Clear all data for this plugin instance
|
|
290
|
+
*
|
|
291
|
+
* @returns Number of entries deleted
|
|
292
|
+
*/
|
|
293
|
+
async clear() {
|
|
294
|
+
return await this.sendRequest("storage/clear", {});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Handle an incoming JSON-RPC response line from the host.
|
|
298
|
+
*
|
|
299
|
+
* Call this method from your readline handler to deliver responses
|
|
300
|
+
* back to pending storage requests.
|
|
301
|
+
*/
|
|
302
|
+
handleResponse(line) {
|
|
303
|
+
const trimmed = line.trim();
|
|
304
|
+
if (!trimmed)
|
|
305
|
+
return;
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(trimmed);
|
|
309
|
+
} catch {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const obj = parsed;
|
|
313
|
+
if (obj.method !== void 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const id = obj.id;
|
|
317
|
+
if (id === void 0 || id === null)
|
|
318
|
+
return;
|
|
319
|
+
const pending = this.pendingRequests.get(id);
|
|
320
|
+
if (!pending)
|
|
321
|
+
return;
|
|
322
|
+
this.pendingRequests.delete(id);
|
|
323
|
+
if ("error" in obj && obj.error) {
|
|
324
|
+
const err = obj.error;
|
|
325
|
+
pending.reject(new StorageError(err.message, err.code, err.data));
|
|
326
|
+
} else {
|
|
327
|
+
pending.resolve(obj.result);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Cancel all pending requests (e.g. on shutdown).
|
|
332
|
+
*/
|
|
333
|
+
cancelAll() {
|
|
334
|
+
for (const [, pending] of this.pendingRequests) {
|
|
335
|
+
pending.reject(new StorageError("Storage client stopped", -1));
|
|
336
|
+
}
|
|
337
|
+
this.pendingRequests.clear();
|
|
338
|
+
}
|
|
339
|
+
// ===========================================================================
|
|
340
|
+
// Internal
|
|
341
|
+
// ===========================================================================
|
|
342
|
+
sendRequest(method, params) {
|
|
343
|
+
const id = this.nextId++;
|
|
344
|
+
const request = {
|
|
345
|
+
jsonrpc: "2.0",
|
|
346
|
+
id,
|
|
347
|
+
method,
|
|
348
|
+
params
|
|
349
|
+
};
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
352
|
+
try {
|
|
353
|
+
this.writeFn(`${JSON.stringify(request)}
|
|
354
|
+
`);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
this.pendingRequests.delete(id);
|
|
357
|
+
const message = err instanceof Error ? err.message : "Unknown write error";
|
|
358
|
+
reject(new StorageError(`Failed to send request: ${message}`, -1));
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// ../sdk-typescript/dist/server.js
|
|
365
|
+
function validateStringFields(params, fields) {
|
|
366
|
+
if (params === null || params === void 0) {
|
|
367
|
+
return { field: "params", message: "params is required" };
|
|
368
|
+
}
|
|
369
|
+
if (typeof params !== "object") {
|
|
370
|
+
return { field: "params", message: "params must be an object" };
|
|
371
|
+
}
|
|
372
|
+
const obj = params;
|
|
373
|
+
for (const field of fields) {
|
|
374
|
+
const value = obj[field];
|
|
375
|
+
if (value === void 0 || value === null) {
|
|
376
|
+
return { field, message: `${field} is required` };
|
|
377
|
+
}
|
|
378
|
+
if (typeof value !== "string") {
|
|
379
|
+
return { field, message: `${field} must be a string` };
|
|
380
|
+
}
|
|
381
|
+
if (value.trim() === "") {
|
|
382
|
+
return { field, message: `${field} cannot be empty` };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
function invalidParamsError(id, error) {
|
|
388
|
+
return {
|
|
389
|
+
jsonrpc: "2.0",
|
|
390
|
+
id,
|
|
391
|
+
error: {
|
|
392
|
+
code: JSON_RPC_ERROR_CODES.INVALID_PARAMS,
|
|
393
|
+
message: `Invalid params: ${error.message}`,
|
|
394
|
+
data: { field: error.field }
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function createPluginServer(options) {
|
|
399
|
+
const { manifest: manifest2, onInitialize, logLevel = "info", label, router } = options;
|
|
400
|
+
const logger2 = createLogger({ name: manifest2.name, level: logLevel });
|
|
401
|
+
const prefix = label ? `${label} plugin` : "plugin";
|
|
402
|
+
const storage = new PluginStorage();
|
|
403
|
+
const hostRpc = new HostRpcClient();
|
|
404
|
+
logger2.info(`Starting ${prefix}: ${manifest2.displayName} v${manifest2.version}`);
|
|
405
|
+
const rl = createInterface({
|
|
406
|
+
input: process.stdin,
|
|
407
|
+
terminal: false
|
|
408
|
+
});
|
|
409
|
+
rl.on("line", (line) => {
|
|
410
|
+
void handleLine(line, manifest2, onInitialize, router, logger2, storage, hostRpc);
|
|
411
|
+
});
|
|
412
|
+
rl.on("close", () => {
|
|
413
|
+
logger2.info("stdin closed, shutting down");
|
|
414
|
+
storage.cancelAll();
|
|
415
|
+
hostRpc.cancelAll();
|
|
416
|
+
process.exit(0);
|
|
417
|
+
});
|
|
418
|
+
process.on("uncaughtException", (error) => {
|
|
419
|
+
logger2.error("Uncaught exception", error);
|
|
420
|
+
process.exit(1);
|
|
421
|
+
});
|
|
422
|
+
process.on("unhandledRejection", (reason) => {
|
|
423
|
+
logger2.error("Unhandled rejection", reason);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function isJsonRpcResponse(obj) {
|
|
427
|
+
if (obj.method !== void 0)
|
|
428
|
+
return false;
|
|
429
|
+
if (obj.id === void 0 || obj.id === null)
|
|
430
|
+
return false;
|
|
431
|
+
return "result" in obj || "error" in obj;
|
|
432
|
+
}
|
|
433
|
+
async function handleLine(line, manifest2, onInitialize, router, logger2, storage, hostRpc) {
|
|
434
|
+
const trimmed = line.trim();
|
|
435
|
+
if (!trimmed)
|
|
436
|
+
return;
|
|
437
|
+
let parsed;
|
|
438
|
+
try {
|
|
439
|
+
parsed = JSON.parse(trimmed);
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
if (parsed && isJsonRpcResponse(parsed)) {
|
|
443
|
+
logger2.debug("Routing reverse-RPC response", { id: parsed.id });
|
|
444
|
+
if (!hostRpc.handleResponse(trimmed)) {
|
|
445
|
+
storage.handleResponse(trimmed);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
let id = null;
|
|
450
|
+
try {
|
|
451
|
+
const request = parsed ?? JSON.parse(trimmed);
|
|
452
|
+
id = request.id;
|
|
453
|
+
logger2.debug(`Received request: ${request.method}`, { id: request.id });
|
|
454
|
+
const response = await runWithParentRequestId(request.id, () => handleRequest(request, manifest2, onInitialize, router, logger2, storage, hostRpc));
|
|
455
|
+
if (response !== null) {
|
|
456
|
+
writeResponse(response);
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (error instanceof SyntaxError) {
|
|
460
|
+
writeResponse({
|
|
461
|
+
jsonrpc: "2.0",
|
|
462
|
+
id: null,
|
|
463
|
+
error: {
|
|
464
|
+
code: JSON_RPC_ERROR_CODES.PARSE_ERROR,
|
|
465
|
+
message: "Parse error: invalid JSON"
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
} else if (error instanceof PluginError) {
|
|
469
|
+
writeResponse({
|
|
470
|
+
jsonrpc: "2.0",
|
|
471
|
+
id,
|
|
472
|
+
error: error.toJsonRpcError()
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
476
|
+
logger2.error("Request failed", error);
|
|
477
|
+
writeResponse({
|
|
478
|
+
jsonrpc: "2.0",
|
|
479
|
+
id,
|
|
480
|
+
error: {
|
|
481
|
+
code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
|
|
482
|
+
message
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async function handleRequest(request, manifest2, onInitialize, router, logger2, storage, hostRpc) {
|
|
489
|
+
const { method, params, id } = request;
|
|
490
|
+
switch (method) {
|
|
491
|
+
case "initialize": {
|
|
492
|
+
const initParams = params ?? {};
|
|
493
|
+
initParams.storage = storage;
|
|
494
|
+
initParams.hostRpc = hostRpc;
|
|
495
|
+
if (onInitialize) {
|
|
496
|
+
await onInitialize(initParams);
|
|
497
|
+
}
|
|
498
|
+
return { jsonrpc: "2.0", id, result: manifest2 };
|
|
499
|
+
}
|
|
500
|
+
case "ping":
|
|
501
|
+
return { jsonrpc: "2.0", id, result: "pong" };
|
|
502
|
+
case "shutdown": {
|
|
503
|
+
logger2.info("Shutdown requested");
|
|
504
|
+
storage.cancelAll();
|
|
505
|
+
hostRpc.cancelAll();
|
|
506
|
+
const response2 = { jsonrpc: "2.0", id, result: null };
|
|
507
|
+
process.stdout.write(`${JSON.stringify(response2)}
|
|
508
|
+
`, () => {
|
|
509
|
+
process.exit(0);
|
|
510
|
+
});
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const response = await router(method, params, id);
|
|
515
|
+
if (response !== null) {
|
|
516
|
+
return response;
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
jsonrpc: "2.0",
|
|
520
|
+
id,
|
|
521
|
+
error: {
|
|
522
|
+
code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
|
|
523
|
+
message: `Method not found: ${method}`
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function writeResponse(response) {
|
|
528
|
+
process.stdout.write(`${JSON.stringify(response)}
|
|
529
|
+
`);
|
|
530
|
+
}
|
|
531
|
+
function success(id, result) {
|
|
532
|
+
return { jsonrpc: "2.0", id, result };
|
|
533
|
+
}
|
|
534
|
+
function validateReleasePollParams(params) {
|
|
535
|
+
return validateStringFields(params, ["sourceId"]);
|
|
536
|
+
}
|
|
537
|
+
function createReleaseSourcePlugin(options) {
|
|
538
|
+
const { manifest: manifest2, provider, onInitialize, logLevel } = options;
|
|
539
|
+
if (!manifest2.capabilities.releaseSource) {
|
|
540
|
+
throw new Error("manifest.capabilities.releaseSource is required for createReleaseSourcePlugin");
|
|
541
|
+
}
|
|
542
|
+
const router = async (method, params, id) => {
|
|
543
|
+
switch (method) {
|
|
544
|
+
case "releases/poll": {
|
|
545
|
+
const err = validateReleasePollParams(params);
|
|
546
|
+
if (err)
|
|
547
|
+
return invalidParamsError(id, err);
|
|
548
|
+
return success(id, await provider.poll(params));
|
|
549
|
+
}
|
|
550
|
+
default:
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
createPluginServer({ manifest: manifest2, onInitialize, logLevel, label: "release-source", router });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ../sdk-typescript/dist/types/releases.js
|
|
558
|
+
var RELEASES_METHODS = {
|
|
559
|
+
/** List tracked series, scoped to what the plugin's manifest declared. */
|
|
560
|
+
LIST_TRACKED: "releases/list_tracked",
|
|
561
|
+
/**
|
|
562
|
+
* Count tracked series scoped to the plugin's `requiresExternalIds`.
|
|
563
|
+
*
|
|
564
|
+
* Plugins call this once at the start of a poll to learn the total
|
|
565
|
+
* denominator before iterating, so subsequent `REPORT_PROGRESS` calls
|
|
566
|
+
* carry a stable `current/total` ratio. Cheap (one batched DB lookup);
|
|
567
|
+
* safe to call from `poll`.
|
|
568
|
+
*/
|
|
569
|
+
COUNT_TRACKED: "releases/count_tracked",
|
|
570
|
+
/**
|
|
571
|
+
* Report intra-poll progress to the host. The host translates this into
|
|
572
|
+
* a `TaskProgressEvent` on the active task's broadcaster; the inbox
|
|
573
|
+
* progress bar updates live. Best-effort — calls outside an active
|
|
574
|
+
* task scope are silently dropped, and rapid back-to-back calls are
|
|
575
|
+
* rate-limited (~10/sec) by the host. Plugins SHOULD call this after
|
|
576
|
+
* each unit of work (e.g. after each polled series) with `current` set
|
|
577
|
+
* to the count of completed units and `total` from `COUNT_TRACKED`.
|
|
578
|
+
*/
|
|
579
|
+
REPORT_PROGRESS: "releases/report_progress",
|
|
580
|
+
/** Submit a candidate to the host's release ledger. */
|
|
581
|
+
RECORD: "releases/record",
|
|
582
|
+
/** Get persisted per-source state (etag, last_polled_at, last_error). */
|
|
583
|
+
SOURCE_STATE_GET: "releases/source_state/get",
|
|
584
|
+
/** Set persisted per-source state (etag only — other fields are host-owned). */
|
|
585
|
+
SOURCE_STATE_SET: "releases/source_state/set",
|
|
586
|
+
/**
|
|
587
|
+
* Replace the set of `release_sources` rows owned by this plugin.
|
|
588
|
+
*
|
|
589
|
+
* Plugins call this from `onInitialize` (and after any config change, which
|
|
590
|
+
* triggers a process restart that re-runs `onInitialize`). Each call carries
|
|
591
|
+
* the plugin's full desired-state list; the host upserts every entry on
|
|
592
|
+
* `(plugin_id, source_key)` and prunes rows whose `source_key` is not in
|
|
593
|
+
* the request. User-managed fields (`enabled`, `pollIntervalS`) are
|
|
594
|
+
* preserved across re-registrations so an admin's overrides aren't
|
|
595
|
+
* trampled by a plugin restart.
|
|
596
|
+
*/
|
|
597
|
+
REGISTER_SOURCES: "releases/register_sources"
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/candidate.ts
|
|
601
|
+
function toSpans(coverage) {
|
|
602
|
+
if (coverage.length === 0) return null;
|
|
603
|
+
return coverage.map((s) => ({ start: s.start, end: s.end }));
|
|
604
|
+
}
|
|
605
|
+
function fmtHighwater(value) {
|
|
606
|
+
return value === null ? "-" : String(value);
|
|
607
|
+
}
|
|
608
|
+
function externalReleaseId(item) {
|
|
609
|
+
return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;
|
|
610
|
+
}
|
|
611
|
+
function feedItemToCandidate(item, match, opts) {
|
|
612
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
613
|
+
return {
|
|
614
|
+
seriesMatch: {
|
|
615
|
+
codexSeriesId: match.codexSeriesId,
|
|
616
|
+
confidence: match.confidence,
|
|
617
|
+
reason: `tsundoku:vote:${match.agreeingProviders.join("+")}`
|
|
618
|
+
},
|
|
619
|
+
externalReleaseId: externalReleaseId(item),
|
|
620
|
+
volumes: toSpans(item.volumeCoverage),
|
|
621
|
+
chapters: toSpans(item.chapterCoverage),
|
|
622
|
+
language: opts.language,
|
|
623
|
+
groupOrUploader: null,
|
|
624
|
+
payloadUrl: `${base}/series/${item.seriesId}`,
|
|
625
|
+
observedAt: opts.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
626
|
+
// Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest
|
|
627
|
+
// thing the feed has to a publish date. Not skew-checked host-side.
|
|
628
|
+
releasedAt: new Date(item.updatedAt * 1e3).toISOString(),
|
|
629
|
+
metadata: {
|
|
630
|
+
tsundokuSeriesId: item.seriesId,
|
|
631
|
+
canonicalTitle: item.canonicalTitle,
|
|
632
|
+
highestVolume: item.highestVolume,
|
|
633
|
+
highestChapter: item.highestChapter
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/fetcher.ts
|
|
639
|
+
var FEED_PATH = "/api/v1/series/feed";
|
|
640
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
641
|
+
function feedUrl(baseUrl) {
|
|
642
|
+
return `${baseUrl.replace(/\/+$/, "")}${FEED_PATH}`;
|
|
643
|
+
}
|
|
644
|
+
async function fetchFeedPage(baseUrl, req, opts = {}) {
|
|
645
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
646
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
647
|
+
const url = feedUrl(baseUrl);
|
|
648
|
+
const headers = {
|
|
649
|
+
Accept: "application/json",
|
|
650
|
+
"Content-Type": "application/json",
|
|
651
|
+
"User-Agent": "Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)"
|
|
652
|
+
};
|
|
653
|
+
const body = JSON.stringify({
|
|
654
|
+
externalIds: req.externalIds,
|
|
655
|
+
cursor: req.cursor,
|
|
656
|
+
limit: req.limit
|
|
657
|
+
});
|
|
658
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
659
|
+
let resp;
|
|
660
|
+
try {
|
|
661
|
+
resp = await fetchImpl(url, { method: "POST", headers, body, signal });
|
|
662
|
+
} catch (err) {
|
|
663
|
+
const msg = err instanceof Error ? err.message : "Unknown fetch error";
|
|
664
|
+
return { kind: "error", status: 0, message: msg };
|
|
665
|
+
}
|
|
666
|
+
if (resp.status !== 200) {
|
|
667
|
+
return {
|
|
668
|
+
kind: "error",
|
|
669
|
+
status: resp.status,
|
|
670
|
+
message: `upstream returned ${resp.status} ${resp.statusText}`.trim()
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
let parsed;
|
|
674
|
+
try {
|
|
675
|
+
parsed = await resp.json();
|
|
676
|
+
} catch (err) {
|
|
677
|
+
const msg = err instanceof Error ? err.message : "invalid JSON";
|
|
678
|
+
return { kind: "error", status: 200, message: `failed to parse feed JSON: ${msg}` };
|
|
679
|
+
}
|
|
680
|
+
if (!isFeedResponse(parsed)) {
|
|
681
|
+
return { kind: "error", status: 200, message: "malformed feed response: missing items[]" };
|
|
682
|
+
}
|
|
683
|
+
return { kind: "ok", data: parsed, status: 200 };
|
|
684
|
+
}
|
|
685
|
+
function isFeedResponse(value) {
|
|
686
|
+
if (value === null || typeof value !== "object") return false;
|
|
687
|
+
const obj = value;
|
|
688
|
+
return Array.isArray(obj.items) && typeof obj.hasMore === "boolean";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// package.json
|
|
692
|
+
var package_default = {
|
|
693
|
+
name: "@ashdev/codex-plugin-release-tsundoku",
|
|
694
|
+
version: "1.36.1",
|
|
695
|
+
description: "Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs",
|
|
696
|
+
main: "dist/index.js",
|
|
697
|
+
bin: "dist/index.js",
|
|
698
|
+
type: "module",
|
|
699
|
+
files: [
|
|
700
|
+
"dist",
|
|
701
|
+
"README.md"
|
|
702
|
+
],
|
|
703
|
+
repository: {
|
|
704
|
+
type: "git",
|
|
705
|
+
url: "https://github.com/AshDevFr/codex.git",
|
|
706
|
+
directory: "plugins/release-tsundoku"
|
|
707
|
+
},
|
|
708
|
+
scripts: {
|
|
709
|
+
build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
|
|
710
|
+
dev: "npm run build -- --watch",
|
|
711
|
+
clean: "rm -rf dist",
|
|
712
|
+
start: "node dist/index.js",
|
|
713
|
+
lint: "biome check .",
|
|
714
|
+
"lint:fix": "biome check --write .",
|
|
715
|
+
typecheck: "tsc --noEmit",
|
|
716
|
+
test: "vitest run --passWithNoTests",
|
|
717
|
+
"test:watch": "vitest",
|
|
718
|
+
prepublishOnly: "npm run lint && npm run build"
|
|
719
|
+
},
|
|
720
|
+
keywords: [
|
|
721
|
+
"codex",
|
|
722
|
+
"plugin",
|
|
723
|
+
"tsundoku",
|
|
724
|
+
"release-source",
|
|
725
|
+
"manga"
|
|
726
|
+
],
|
|
727
|
+
author: "Codex",
|
|
728
|
+
license: "MIT",
|
|
729
|
+
engines: {
|
|
730
|
+
node: ">=22.0.0"
|
|
731
|
+
},
|
|
732
|
+
dependencies: {
|
|
733
|
+
"@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
|
|
734
|
+
},
|
|
735
|
+
devDependencies: {
|
|
736
|
+
"@biomejs/biome": "^2.4.4",
|
|
737
|
+
"@types/node": "^22.0.0",
|
|
738
|
+
esbuild: "^0.27.3",
|
|
739
|
+
typescript: "^5.9.3",
|
|
740
|
+
vitest: "^4.0.18"
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// src/manifest.ts
|
|
745
|
+
var CODEX_TO_TSUNDOKU_PROVIDER = {
|
|
746
|
+
mangabaka: "mangabaka",
|
|
747
|
+
anilist: "anilist",
|
|
748
|
+
myanimelist: "mal",
|
|
749
|
+
mangaupdates: "mangaupdates",
|
|
750
|
+
kitsu: "kitsu",
|
|
751
|
+
shikimori: "shikimori",
|
|
752
|
+
animeplanet: "anime_planet",
|
|
753
|
+
animenewsnetwork: "anime_news_network"
|
|
754
|
+
};
|
|
755
|
+
var CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);
|
|
756
|
+
var manifest = {
|
|
757
|
+
name: "release-tsundoku",
|
|
758
|
+
displayName: "Tsundoku Releases",
|
|
759
|
+
version: package_default.version,
|
|
760
|
+
description: "Announces new volume/chapter coverage for tracked series via a Tsundoku instance's incremental series feed. Matches series by exact external IDs (no fuzzy matching) and walks the feed by cursor, persisting its position between polls.",
|
|
761
|
+
author: "Codex",
|
|
762
|
+
homepage: "https://github.com/AshDevFr/codex",
|
|
763
|
+
protocolVersion: "1.1",
|
|
764
|
+
capabilities: {
|
|
765
|
+
releaseSource: {
|
|
766
|
+
kinds: ["api-feed"],
|
|
767
|
+
requiresAliases: false,
|
|
768
|
+
requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],
|
|
769
|
+
canAnnounceChapters: true,
|
|
770
|
+
canAnnounceVolumes: true
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
configSchema: {
|
|
774
|
+
description: "Tsundoku plugin configuration. Point `baseUrl` at your Tsundoku instance; the plugin polls its public `/api/v1/series/feed` endpoint and matches results to your tracked series by external ID.",
|
|
775
|
+
fields: [
|
|
776
|
+
{
|
|
777
|
+
key: "baseUrl",
|
|
778
|
+
label: "Tsundoku Base URL",
|
|
779
|
+
description: "Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.",
|
|
780
|
+
type: "string",
|
|
781
|
+
required: true,
|
|
782
|
+
example: "https://tsundoku.example.com"
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
key: "defaultLanguage",
|
|
786
|
+
label: "Default Language",
|
|
787
|
+
description: "ISO 639-1 language tag stamped on every announcement. The Tsundoku feed tracks official release coverage and carries no language of its own, so a default is required. Per-series language preferences on each series' tracking config still gate the high-water mark host-side.",
|
|
788
|
+
type: "string",
|
|
789
|
+
required: false,
|
|
790
|
+
default: "en",
|
|
791
|
+
example: "en"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
key: "pageLimit",
|
|
795
|
+
label: "Feed Page Size",
|
|
796
|
+
description: "Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.",
|
|
797
|
+
type: "number",
|
|
798
|
+
required: false,
|
|
799
|
+
default: 100
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
key: "requestTimeoutMs",
|
|
803
|
+
label: "Request Timeout (ms)",
|
|
804
|
+
description: "How long to wait for a single feed page before giving up. Defaults to 10000 (10 seconds).",
|
|
805
|
+
type: "number",
|
|
806
|
+
required: false,
|
|
807
|
+
default: 1e4
|
|
808
|
+
}
|
|
809
|
+
]
|
|
810
|
+
},
|
|
811
|
+
userDescription: "Announces new volumes and chapters for series you've tracked, using a Tsundoku instance as the source. Matches your series by external ID (MangaBaka, AniList, MAL, and more). Notification-only \u2014 Codex does not download anything.",
|
|
812
|
+
adminSetupInstructions: "1. Set `baseUrl` to your Tsundoku instance URL (e.g. `https://tsundoku.example.com`) and save. The plugin auto-registers a single source row (`Tsundoku Releases`) in **Settings \u2192 Release tracking**, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, make sure it has at least one external ID Tsundoku also knows (MangaBaka, AniList, MAL, MangaUpdates, Kitsu, Shikimori, Anime-Planet, or Anime News Network) \u2014 populate these via a metadata refresh or by pasting them in the series tracking panel. 3. Optional: adjust `defaultLanguage` (default `en`), `pageLimit`, and `requestTimeoutMs`. The Tsundoku feed endpoint is public; no credentials are needed. Note: the feed is incremental, so newly tracked series only announce on their *next* Tsundoku coverage change."
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// src/matcher.ts
|
|
816
|
+
var PROVIDER_WEIGHTS = {
|
|
817
|
+
mangabaka: 3,
|
|
818
|
+
anilist: 2
|
|
819
|
+
};
|
|
820
|
+
var DEFAULT_WEIGHT = 1;
|
|
821
|
+
function weightOf(provider) {
|
|
822
|
+
return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;
|
|
823
|
+
}
|
|
824
|
+
function indexKey(provider, externalId) {
|
|
825
|
+
return `${provider}:${externalId}`;
|
|
826
|
+
}
|
|
827
|
+
function buildMatchContext(entries) {
|
|
828
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
829
|
+
const series = /* @__PURE__ */ new Map();
|
|
830
|
+
for (const entry of entries) {
|
|
831
|
+
const ids = entry.externalIds;
|
|
832
|
+
if (!ids) continue;
|
|
833
|
+
const map = /* @__PURE__ */ new Map();
|
|
834
|
+
for (const [codexProvider, externalId] of Object.entries(ids)) {
|
|
835
|
+
if (!externalId) continue;
|
|
836
|
+
const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;
|
|
837
|
+
map.set(provider, externalId);
|
|
838
|
+
const key = indexKey(provider, externalId);
|
|
839
|
+
const arr = byKey.get(key);
|
|
840
|
+
if (arr) {
|
|
841
|
+
arr.push(entry.seriesId);
|
|
842
|
+
} else {
|
|
843
|
+
byKey.set(key, [entry.seriesId]);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (map.size > 0) {
|
|
847
|
+
series.set(entry.seriesId, map);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return { byKey, series };
|
|
851
|
+
}
|
|
852
|
+
function externalIdFilter(ctx) {
|
|
853
|
+
return [...ctx.byKey.keys()];
|
|
854
|
+
}
|
|
855
|
+
function confidenceForScore(score) {
|
|
856
|
+
return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));
|
|
857
|
+
}
|
|
858
|
+
function matchItem(item, ctx) {
|
|
859
|
+
const itemMap = /* @__PURE__ */ new Map();
|
|
860
|
+
for (const ext of item.externalIds) {
|
|
861
|
+
if (ext.externalId) {
|
|
862
|
+
itemMap.set(ext.provider, ext.externalId);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
866
|
+
for (const [provider, id] of itemMap) {
|
|
867
|
+
const arr = ctx.byKey.get(indexKey(provider, id));
|
|
868
|
+
if (arr) {
|
|
869
|
+
for (const sid of arr) candidates.add(sid);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (candidates.size === 0) return null;
|
|
873
|
+
let best = null;
|
|
874
|
+
let tiedAtBest = false;
|
|
875
|
+
for (const cid of candidates) {
|
|
876
|
+
const cSeries = ctx.series.get(cid);
|
|
877
|
+
if (!cSeries) continue;
|
|
878
|
+
let agree = 0;
|
|
879
|
+
let disagree = 0;
|
|
880
|
+
const agreeing = [];
|
|
881
|
+
for (const [provider, idVal] of itemMap) {
|
|
882
|
+
const cVal = cSeries.get(provider);
|
|
883
|
+
if (cVal === void 0) continue;
|
|
884
|
+
const w = weightOf(provider);
|
|
885
|
+
if (cVal === idVal) {
|
|
886
|
+
agree += w;
|
|
887
|
+
agreeing.push({ provider, weight: w });
|
|
888
|
+
} else {
|
|
889
|
+
disagree += w;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const score = agree - disagree;
|
|
893
|
+
if (score <= 0) continue;
|
|
894
|
+
if (!best || score > best.score) {
|
|
895
|
+
agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));
|
|
896
|
+
best = {
|
|
897
|
+
codexSeriesId: cid,
|
|
898
|
+
score,
|
|
899
|
+
confidence: confidenceForScore(score),
|
|
900
|
+
agreeingProviders: agreeing.map((a) => a.provider)
|
|
901
|
+
};
|
|
902
|
+
tiedAtBest = false;
|
|
903
|
+
} else if (score === best.score) {
|
|
904
|
+
tiedAtBest = true;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (!best || tiedAtBest) return null;
|
|
908
|
+
return best;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/index.ts
|
|
912
|
+
var logger = createLogger({ name: manifest.name, level: "info" });
|
|
913
|
+
var DEFAULT_PAGE_LIMIT = 100;
|
|
914
|
+
var MAX_PAGE_LIMIT = 500;
|
|
915
|
+
var DEFAULT_TIMEOUT_MS2 = 1e4;
|
|
916
|
+
var MIN_TIMEOUT_MS = 1e3;
|
|
917
|
+
var MAX_TIMEOUT_MS = 6e4;
|
|
918
|
+
var DEFAULT_LANGUAGE = "en";
|
|
919
|
+
var state = {
|
|
920
|
+
hostRpc: null,
|
|
921
|
+
baseUrl: "",
|
|
922
|
+
defaultLanguage: DEFAULT_LANGUAGE,
|
|
923
|
+
pageLimit: DEFAULT_PAGE_LIMIT,
|
|
924
|
+
requestTimeoutMs: DEFAULT_TIMEOUT_MS2
|
|
925
|
+
};
|
|
926
|
+
function _resetState() {
|
|
927
|
+
state.hostRpc = null;
|
|
928
|
+
state.baseUrl = "";
|
|
929
|
+
state.defaultLanguage = DEFAULT_LANGUAGE;
|
|
930
|
+
state.pageLimit = DEFAULT_PAGE_LIMIT;
|
|
931
|
+
state.requestTimeoutMs = DEFAULT_TIMEOUT_MS2;
|
|
932
|
+
}
|
|
933
|
+
function normalizeBaseUrl(raw) {
|
|
934
|
+
return raw.trim().replace(/\/+$/, "");
|
|
935
|
+
}
|
|
936
|
+
async function registerSources(rpc) {
|
|
937
|
+
const sources = [
|
|
938
|
+
{
|
|
939
|
+
sourceKey: "default",
|
|
940
|
+
displayName: "Tsundoku Releases",
|
|
941
|
+
kind: "api-feed",
|
|
942
|
+
config: null
|
|
943
|
+
}
|
|
944
|
+
];
|
|
945
|
+
try {
|
|
946
|
+
return await rpc.call(
|
|
947
|
+
RELEASES_METHODS.REGISTER_SOURCES,
|
|
948
|
+
{ sources }
|
|
949
|
+
);
|
|
950
|
+
} catch (err) {
|
|
951
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
952
|
+
logger.error(`register_sources failed: ${reason}`);
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
var TRACKED_PAGE_SIZE = 200;
|
|
957
|
+
async function* iterateTrackedSeries(rpc, sourceId) {
|
|
958
|
+
let offset = 0;
|
|
959
|
+
while (true) {
|
|
960
|
+
const page = await rpc.call(RELEASES_METHODS.LIST_TRACKED, {
|
|
961
|
+
sourceId,
|
|
962
|
+
offset,
|
|
963
|
+
limit: TRACKED_PAGE_SIZE
|
|
964
|
+
});
|
|
965
|
+
for (const entry of page.tracked) {
|
|
966
|
+
yield entry;
|
|
967
|
+
}
|
|
968
|
+
if (page.nextOffset === void 0 || page.tracked.length === 0) return;
|
|
969
|
+
offset = page.nextOffset;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
async function recordCandidate(rpc, sourceId, candidate) {
|
|
973
|
+
try {
|
|
974
|
+
return await rpc.call(RELEASES_METHODS.RECORD, { sourceId, candidate });
|
|
975
|
+
} catch (err) {
|
|
976
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
977
|
+
const code = err instanceof HostRpcError ? ` (code ${err.code})` : "";
|
|
978
|
+
logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function reportProgress(rpc, current, total, message) {
|
|
983
|
+
try {
|
|
984
|
+
await rpc.call(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });
|
|
985
|
+
} catch (err) {
|
|
986
|
+
if (err instanceof HostRpcError && err.code === -32601) return;
|
|
987
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
988
|
+
logger.debug(`report_progress dropped: ${reason}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async function poll(params, rpc, deps) {
|
|
992
|
+
const sourceId = params.sourceId;
|
|
993
|
+
const trackedEntries = [];
|
|
994
|
+
for await (const entry of iterateTrackedSeries(rpc, sourceId)) {
|
|
995
|
+
trackedEntries.push(entry);
|
|
996
|
+
}
|
|
997
|
+
const ctx = buildMatchContext(trackedEntries);
|
|
998
|
+
const externalIds = externalIdFilter(ctx);
|
|
999
|
+
if (externalIds.length === 0) {
|
|
1000
|
+
logger.info(
|
|
1001
|
+
`poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`
|
|
1002
|
+
);
|
|
1003
|
+
return {
|
|
1004
|
+
notModified: false,
|
|
1005
|
+
upstreamStatus: 200,
|
|
1006
|
+
parsed: 0,
|
|
1007
|
+
matched: 0,
|
|
1008
|
+
recorded: 0,
|
|
1009
|
+
deduped: 0
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
let cursor = null;
|
|
1013
|
+
let parsed = 0;
|
|
1014
|
+
let worstStatus = 200;
|
|
1015
|
+
let pagesFetched = 0;
|
|
1016
|
+
const hits = [];
|
|
1017
|
+
while (true) {
|
|
1018
|
+
const result = await fetchFeedPage(
|
|
1019
|
+
deps.baseUrl,
|
|
1020
|
+
{ externalIds, cursor, limit: deps.pageLimit },
|
|
1021
|
+
{ timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl }
|
|
1022
|
+
);
|
|
1023
|
+
if (result.kind === "error") {
|
|
1024
|
+
worstStatus = Math.max(worstStatus, result.status);
|
|
1025
|
+
if (pagesFetched === 0) {
|
|
1026
|
+
throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);
|
|
1027
|
+
}
|
|
1028
|
+
logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
pagesFetched++;
|
|
1032
|
+
const page = result.data;
|
|
1033
|
+
for (const item of page.items) {
|
|
1034
|
+
parsed++;
|
|
1035
|
+
const match = matchItem(item, ctx);
|
|
1036
|
+
if (match) {
|
|
1037
|
+
hits.push({ item, match });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);
|
|
1041
|
+
const next = page.nextCursor ?? null;
|
|
1042
|
+
if (!page.hasMore) break;
|
|
1043
|
+
if (!next) {
|
|
1044
|
+
logger.warn("feed reported hasMore but no nextCursor; stopping walk");
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
if (page.items.length === 0) break;
|
|
1048
|
+
cursor = next;
|
|
1049
|
+
}
|
|
1050
|
+
const byCodex = /* @__PURE__ */ new Map();
|
|
1051
|
+
for (const hit of hits) {
|
|
1052
|
+
const arr = byCodex.get(hit.match.codexSeriesId);
|
|
1053
|
+
if (arr) {
|
|
1054
|
+
arr.push(hit);
|
|
1055
|
+
} else {
|
|
1056
|
+
byCodex.set(hit.match.codexSeriesId, [hit]);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
let matched = 0;
|
|
1060
|
+
let recorded = 0;
|
|
1061
|
+
let deduped = 0;
|
|
1062
|
+
let ambiguous = 0;
|
|
1063
|
+
let superseded = 0;
|
|
1064
|
+
for (const [codexSeriesId, group] of byCodex) {
|
|
1065
|
+
group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);
|
|
1066
|
+
if (group.length > 1 && group[0].match.score === group[1].match.score && group[0].item.seriesId !== group[1].item.seriesId) {
|
|
1067
|
+
ambiguous += group.length;
|
|
1068
|
+
logger.warn(
|
|
1069
|
+
`ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`
|
|
1070
|
+
);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
superseded += group.length - 1;
|
|
1074
|
+
const { item, match } = group[0];
|
|
1075
|
+
matched++;
|
|
1076
|
+
const candidate = feedItemToCandidate(item, match, {
|
|
1077
|
+
baseUrl: deps.baseUrl,
|
|
1078
|
+
language: deps.language
|
|
1079
|
+
});
|
|
1080
|
+
const outcome = await recordCandidate(rpc, sourceId, candidate);
|
|
1081
|
+
if (!outcome) continue;
|
|
1082
|
+
if (outcome.deduped) {
|
|
1083
|
+
deduped++;
|
|
1084
|
+
} else {
|
|
1085
|
+
recorded++;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
logger.info(
|
|
1089
|
+
`poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`
|
|
1090
|
+
);
|
|
1091
|
+
return {
|
|
1092
|
+
notModified: false,
|
|
1093
|
+
upstreamStatus: worstStatus,
|
|
1094
|
+
parsed,
|
|
1095
|
+
matched,
|
|
1096
|
+
recorded,
|
|
1097
|
+
deduped
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
createReleaseSourcePlugin({
|
|
1101
|
+
manifest,
|
|
1102
|
+
provider: {
|
|
1103
|
+
async poll(params) {
|
|
1104
|
+
if (!state.hostRpc) {
|
|
1105
|
+
throw new Error("Plugin not initialized: host RPC client missing");
|
|
1106
|
+
}
|
|
1107
|
+
if (!state.baseUrl) {
|
|
1108
|
+
throw new Error("Plugin not configured: baseUrl is required");
|
|
1109
|
+
}
|
|
1110
|
+
return poll(params, state.hostRpc, {
|
|
1111
|
+
baseUrl: state.baseUrl,
|
|
1112
|
+
language: state.defaultLanguage,
|
|
1113
|
+
pageLimit: state.pageLimit,
|
|
1114
|
+
timeoutMs: state.requestTimeoutMs
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
logLevel: "info",
|
|
1119
|
+
async onInitialize(params) {
|
|
1120
|
+
state.hostRpc = params.hostRpc;
|
|
1121
|
+
const ac = params.adminConfig ?? {};
|
|
1122
|
+
if (typeof ac.baseUrl === "string") {
|
|
1123
|
+
state.baseUrl = normalizeBaseUrl(ac.baseUrl);
|
|
1124
|
+
}
|
|
1125
|
+
if (typeof ac.defaultLanguage === "string" && ac.defaultLanguage.trim().length > 0) {
|
|
1126
|
+
state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();
|
|
1127
|
+
}
|
|
1128
|
+
if (typeof ac.pageLimit === "number" && Number.isFinite(ac.pageLimit)) {
|
|
1129
|
+
state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));
|
|
1130
|
+
}
|
|
1131
|
+
if (typeof ac.requestTimeoutMs === "number" && Number.isFinite(ac.requestTimeoutMs)) {
|
|
1132
|
+
state.requestTimeoutMs = Math.max(
|
|
1133
|
+
MIN_TIMEOUT_MS,
|
|
1134
|
+
Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS)
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
if (!state.baseUrl) {
|
|
1138
|
+
logger.warn(
|
|
1139
|
+
"initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then"
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
logger.info(
|
|
1143
|
+
`initialized: baseUrl=${state.baseUrl || "(unset)"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`
|
|
1144
|
+
);
|
|
1145
|
+
queueMicrotask(() => {
|
|
1146
|
+
void registerSources(params.hostRpc).then((result) => {
|
|
1147
|
+
if (result) {
|
|
1148
|
+
logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
logger.info("Tsundoku release-source plugin started");
|
|
1155
|
+
export {
|
|
1156
|
+
_resetState,
|
|
1157
|
+
normalizeBaseUrl,
|
|
1158
|
+
poll,
|
|
1159
|
+
registerSources
|
|
1160
|
+
};
|
|
1161
|
+
//# sourceMappingURL=index.js.map
|