@ashdev/codex-plugin-release-mangaupdates 1.18.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/dist/index.js ADDED
@@ -0,0 +1,1127 @@
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
+ /** Submit a candidate to the host's release ledger. */
562
+ RECORD: "releases/record",
563
+ /** Get persisted per-source state (etag, last_polled_at, last_error). */
564
+ SOURCE_STATE_GET: "releases/source_state/get",
565
+ /** Set persisted per-source state (etag only — other fields are host-owned). */
566
+ SOURCE_STATE_SET: "releases/source_state/set",
567
+ /**
568
+ * Replace the set of `release_sources` rows owned by this plugin.
569
+ *
570
+ * Plugins call this from `onInitialize` (and after any config change, which
571
+ * triggers a process restart that re-runs `onInitialize`). Each call carries
572
+ * the plugin's full desired-state list; the host upserts every entry on
573
+ * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in
574
+ * the request. User-managed fields (`enabled`, `pollIntervalS`) are
575
+ * preserved across re-registrations so an admin's overrides aren't
576
+ * trampled by a plugin restart.
577
+ */
578
+ REGISTER_SOURCES: "releases/register_sources"
579
+ };
580
+
581
+ // src/fetcher.ts
582
+ var MANGAUPDATES_RSS_BASE = "https://api.mangaupdates.com/v1/series";
583
+ function normalizeMangaUpdatesId(raw) {
584
+ const trimmed = raw.trim();
585
+ if (trimmed.length === 0) return null;
586
+ if (/^\d+$/.test(trimmed)) return trimmed;
587
+ if (!/^[0-9a-z]+$/i.test(trimmed)) return null;
588
+ const decoded = Number.parseInt(trimmed, 36);
589
+ if (!Number.isFinite(decoded) || decoded <= 0) return null;
590
+ return String(decoded);
591
+ }
592
+ function feedUrl(mangaUpdatesId) {
593
+ const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;
594
+ return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;
595
+ }
596
+ async function fetchSeriesFeed(mangaUpdatesId, previousEtag, opts = {}) {
597
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
598
+ const timeoutMs = opts.timeoutMs ?? 1e4;
599
+ const url = feedUrl(mangaUpdatesId);
600
+ const headers = {
601
+ Accept: "application/rss+xml, application/xml;q=0.9, */*;q=0.5",
602
+ "User-Agent": "Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)"
603
+ };
604
+ if (previousEtag) {
605
+ headers["If-None-Match"] = previousEtag;
606
+ }
607
+ const signal = AbortSignal.timeout(timeoutMs);
608
+ let resp;
609
+ try {
610
+ resp = await fetchImpl(url, { method: "GET", headers, signal });
611
+ } catch (err) {
612
+ const msg = err instanceof Error ? err.message : "Unknown fetch error";
613
+ return { kind: "error", status: 0, message: msg };
614
+ }
615
+ if (resp.status === 304) {
616
+ return { kind: "notModified", status: 304 };
617
+ }
618
+ if (resp.status === 200) {
619
+ const body = await resp.text();
620
+ const etag = resp.headers.get("etag");
621
+ return { kind: "ok", body, etag, status: 200 };
622
+ }
623
+ return {
624
+ kind: "error",
625
+ status: resp.status,
626
+ message: `upstream returned ${resp.status} ${resp.statusText}`
627
+ };
628
+ }
629
+
630
+ // src/parser.ts
631
+ var UNKNOWN_LANGUAGE = "unknown";
632
+ function decodeXmlText(raw) {
633
+ let s = raw.trim();
634
+ const cdataMatch = s.match(/^<!\[CDATA\[([\s\S]*?)]]>$/);
635
+ if (cdataMatch?.[1] !== void 0) {
636
+ s = cdataMatch[1];
637
+ }
638
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
639
+ }
640
+ function extractTagText(xml, tag) {
641
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
642
+ const m = xml.match(re);
643
+ if (!m?.[1]) return null;
644
+ return decodeXmlText(m[1]);
645
+ }
646
+ function splitItems(xml) {
647
+ const out = [];
648
+ const re = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
649
+ for (; ; ) {
650
+ const match = re.exec(xml);
651
+ if (match === null) break;
652
+ if (match[1] !== void 0) out.push(match[1]);
653
+ }
654
+ return out;
655
+ }
656
+ function parseTitle(title) {
657
+ const trimmed = title.trim();
658
+ let chapter = null;
659
+ const chMatch = trimmed.match(/\bc(?:h)?\.?\s*([0-9]+(?:\.[0-9]+)?)\b/i);
660
+ if (chMatch?.[1]) {
661
+ const n = Number.parseFloat(chMatch[1]);
662
+ if (Number.isFinite(n)) chapter = n;
663
+ }
664
+ let volume = null;
665
+ const volMatch = trimmed.match(/\bv(?:ol)?\.?\s*([0-9]+)\b/i);
666
+ if (volMatch?.[1]) {
667
+ const n = Number.parseInt(volMatch[1], 10);
668
+ if (Number.isFinite(n)) volume = n;
669
+ }
670
+ let group = null;
671
+ const groupMatch = trimmed.match(/\bby\s+(.+?)(?:\s*\([a-z]{2,3}\)\s*)?$/i);
672
+ if (groupMatch?.[1]) {
673
+ const candidate = groupMatch[1].trim();
674
+ if (candidate.length > 0) group = candidate;
675
+ }
676
+ let language = "en";
677
+ const langMatch = trimmed.match(/\(([a-z]{2,3})\)\s*$/i);
678
+ if (langMatch?.[1]) {
679
+ language = langMatch[1].toLowerCase();
680
+ }
681
+ return { chapter, volume, group, language };
682
+ }
683
+ function pubDateToIso(raw) {
684
+ if (raw) {
685
+ const d = new Date(raw);
686
+ if (!Number.isNaN(d.getTime())) return d.toISOString();
687
+ }
688
+ return (/* @__PURE__ */ new Date()).toISOString();
689
+ }
690
+ function deriveExternalReleaseId(guid, link, title, pubDate) {
691
+ if (guid && guid.trim().length > 0) return guid.trim();
692
+ if (link && link.trim().length > 0) return link.trim();
693
+ const fallback = `${title}|${pubDate ?? ""}`;
694
+ let h = 5381;
695
+ for (let i = 0; i < fallback.length; i++) {
696
+ h = (h << 5) + h + fallback.charCodeAt(i) | 0;
697
+ }
698
+ return `t:${(h >>> 0).toString(36)}`;
699
+ }
700
+ function parseItem(itemXml) {
701
+ const title = extractTagText(itemXml, "title");
702
+ if (!title) return null;
703
+ const link = extractTagText(itemXml, "link");
704
+ const guid = extractTagText(itemXml, "guid");
705
+ const pubDate = extractTagText(itemXml, "pubDate");
706
+ const { chapter, volume, group, language } = parseTitle(title);
707
+ return {
708
+ externalReleaseId: deriveExternalReleaseId(guid, link, title, pubDate),
709
+ title,
710
+ chapter,
711
+ volume,
712
+ group,
713
+ language,
714
+ link: link ?? "",
715
+ observedAt: pubDateToIso(pubDate)
716
+ };
717
+ }
718
+ function parseFeed(xml) {
719
+ return splitItems(xml).map(parseItem).filter((i) => i !== null);
720
+ }
721
+
722
+ // src/filter.ts
723
+ function resolveFilters(input) {
724
+ const languages = dedupePreserveOrder(
725
+ input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0)
726
+ );
727
+ const blockedGroups = new Set(
728
+ input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0)
729
+ );
730
+ return {
731
+ languages,
732
+ blockedGroups,
733
+ includeUnknownLanguage: input.includeUnknownLanguage ?? false
734
+ };
735
+ }
736
+ function parseCommaList(raw) {
737
+ if (typeof raw !== "string") return [];
738
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
739
+ }
740
+ function passesFilters(item, filters) {
741
+ if (item.language === UNKNOWN_LANGUAGE) {
742
+ if (!filters.includeUnknownLanguage) return false;
743
+ } else if (filters.languages.length > 0) {
744
+ if (!filters.languages.includes(item.language.toLowerCase())) return false;
745
+ }
746
+ if (item.group !== null && filters.blockedGroups.size > 0) {
747
+ if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;
748
+ }
749
+ return true;
750
+ }
751
+ function dedupePreserveOrder(xs) {
752
+ const seen = /* @__PURE__ */ new Set();
753
+ const out = [];
754
+ for (const x of xs) {
755
+ if (!seen.has(x)) {
756
+ seen.add(x);
757
+ out.push(x);
758
+ }
759
+ }
760
+ return out;
761
+ }
762
+
763
+ // package.json
764
+ var package_default = {
765
+ name: "@ashdev/codex-plugin-release-mangaupdates",
766
+ version: "1.18.0",
767
+ description: "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
768
+ main: "dist/index.js",
769
+ bin: "dist/index.js",
770
+ type: "module",
771
+ files: [
772
+ "dist",
773
+ "README.md"
774
+ ],
775
+ repository: {
776
+ type: "git",
777
+ url: "https://github.com/AshDevFr/codex.git",
778
+ directory: "plugins/release-mangaupdates"
779
+ },
780
+ scripts: {
781
+ build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
782
+ dev: "npm run build -- --watch",
783
+ clean: "rm -rf dist",
784
+ start: "node dist/index.js",
785
+ lint: "biome check .",
786
+ "lint:fix": "biome check --write .",
787
+ typecheck: "tsc --noEmit",
788
+ test: "vitest run --passWithNoTests",
789
+ "test:watch": "vitest",
790
+ prepublishOnly: "npm run lint && npm run build"
791
+ },
792
+ keywords: [
793
+ "codex",
794
+ "plugin",
795
+ "mangaupdates",
796
+ "release-source",
797
+ "manga"
798
+ ],
799
+ author: "Codex",
800
+ license: "MIT",
801
+ engines: {
802
+ node: ">=22.0.0"
803
+ },
804
+ dependencies: {
805
+ "@ashdev/codex-plugin-sdk": "file:../sdk-typescript"
806
+ },
807
+ devDependencies: {
808
+ "@biomejs/biome": "^2.4.4",
809
+ "@types/node": "^22.0.0",
810
+ esbuild: "^0.27.3",
811
+ typescript: "^5.9.3",
812
+ vitest: "^4.0.18"
813
+ }
814
+ };
815
+
816
+ // src/manifest.ts
817
+ var EXTERNAL_ID_SOURCE_MANGAUPDATES = "mangaupdates";
818
+ var manifest = {
819
+ name: "release-mangaupdates",
820
+ displayName: "MangaUpdates Releases",
821
+ version: package_default.version,
822
+ description: "Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.",
823
+ author: "Codex",
824
+ homepage: "https://github.com/AshDevFr/codex",
825
+ protocolVersion: "1.1",
826
+ capabilities: {
827
+ releaseSource: {
828
+ kinds: ["rss-series"],
829
+ requiresAliases: false,
830
+ requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],
831
+ canAnnounceChapters: true,
832
+ canAnnounceVolumes: true
833
+ }
834
+ },
835
+ configSchema: {
836
+ description: "MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.",
837
+ fields: [
838
+ {
839
+ key: "blockedGroups",
840
+ label: "Blocked Scanlation Groups",
841
+ description: "Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.",
842
+ type: "string",
843
+ required: false,
844
+ default: "",
845
+ example: "LowQualityScans,MTL Group"
846
+ },
847
+ {
848
+ key: "requestTimeoutMs",
849
+ label: "Request Timeout (ms)",
850
+ description: "How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).",
851
+ type: "number",
852
+ required: false,
853
+ default: 1e4
854
+ }
855
+ ]
856
+ },
857
+ userDescription: "Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.",
858
+ adminSetupInstructions: "1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public."
859
+ };
860
+
861
+ // src/index.ts
862
+ var logger = createLogger({ name: manifest.name, level: "info" });
863
+ var state = {
864
+ hostRpc: null,
865
+ blockedGroupsCsv: "",
866
+ requestTimeoutMs: 1e4
867
+ };
868
+ function _resetState() {
869
+ state.hostRpc = null;
870
+ state.blockedGroupsCsv = "";
871
+ state.requestTimeoutMs = 1e4;
872
+ }
873
+ async function listTracked(rpc, sourceId, offset, limit) {
874
+ return rpc.call(RELEASES_METHODS.LIST_TRACKED, {
875
+ sourceId,
876
+ offset,
877
+ limit
878
+ });
879
+ }
880
+ async function recordCandidate(rpc, sourceId, candidate) {
881
+ try {
882
+ return await rpc.call(RELEASES_METHODS.RECORD, {
883
+ sourceId,
884
+ candidate
885
+ });
886
+ } catch (err) {
887
+ if (err instanceof HostRpcError) {
888
+ logger.warn(
889
+ `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`
890
+ );
891
+ } else {
892
+ const msg = err instanceof Error ? err.message : "unknown error";
893
+ logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);
894
+ }
895
+ return null;
896
+ }
897
+ }
898
+ async function* iterateTrackedSeries(rpc, sourceId) {
899
+ const pageSize = 200;
900
+ let offset = 0;
901
+ while (true) {
902
+ const page = await listTracked(rpc, sourceId, offset, pageSize);
903
+ for (const entry of page.tracked) {
904
+ yield entry;
905
+ }
906
+ if (page.nextOffset === void 0 || page.tracked.length === 0) return;
907
+ offset = page.nextOffset;
908
+ }
909
+ }
910
+ function effectiveLanguagesForSeries(_entry) {
911
+ return [];
912
+ }
913
+ function toCandidate(entry, item) {
914
+ const candidate = {
915
+ seriesMatch: {
916
+ codexSeriesId: entry.seriesId,
917
+ confidence: 1,
918
+ reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? ""}`
919
+ },
920
+ externalReleaseId: item.externalReleaseId,
921
+ chapter: item.chapter,
922
+ volume: item.volume,
923
+ language: item.language,
924
+ groupOrUploader: item.group,
925
+ payloadUrl: item.link.length > 0 ? item.link : `urn:mu:${item.externalReleaseId}`,
926
+ observedAt: item.observedAt
927
+ };
928
+ return candidate;
929
+ }
930
+ async function pollSeries(rpc, sourceId, entry, options) {
931
+ const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];
932
+ if (!muId) {
933
+ return {
934
+ seriesId: entry.seriesId,
935
+ fetched: false,
936
+ notModified: false,
937
+ parsed: 0,
938
+ matched: 0,
939
+ recorded: 0,
940
+ deduped: 0,
941
+ upstreamStatus: 0,
942
+ etag: null,
943
+ error: "missing mangaupdates external ID"
944
+ };
945
+ }
946
+ const result = await fetchSeriesFeed(muId, null, {
947
+ fetchImpl: options.fetchImpl,
948
+ timeoutMs: options.timeoutMs
949
+ });
950
+ if (result.kind === "notModified") {
951
+ return {
952
+ seriesId: entry.seriesId,
953
+ fetched: true,
954
+ notModified: true,
955
+ parsed: 0,
956
+ matched: 0,
957
+ recorded: 0,
958
+ deduped: 0,
959
+ upstreamStatus: 304,
960
+ etag: null,
961
+ error: ""
962
+ };
963
+ }
964
+ if (result.kind === "error") {
965
+ return {
966
+ seriesId: entry.seriesId,
967
+ fetched: false,
968
+ notModified: false,
969
+ parsed: 0,
970
+ matched: 0,
971
+ recorded: 0,
972
+ deduped: 0,
973
+ upstreamStatus: result.status,
974
+ etag: null,
975
+ error: result.message
976
+ };
977
+ }
978
+ const items = parseFeed(result.body);
979
+ const filters = resolveFilters({
980
+ languages: effectiveLanguagesForSeries(entry),
981
+ blockedGroups: options.blockedGroups
982
+ });
983
+ let matched = 0;
984
+ let recorded = 0;
985
+ let deduped = 0;
986
+ for (const item of items) {
987
+ if (!passesFilters(item, filters)) continue;
988
+ matched++;
989
+ const candidate = toCandidate(entry, item);
990
+ const outcome = await recordCandidate(rpc, sourceId, candidate);
991
+ if (!outcome) continue;
992
+ if (outcome.deduped) {
993
+ deduped++;
994
+ } else {
995
+ recorded++;
996
+ }
997
+ }
998
+ return {
999
+ seriesId: entry.seriesId,
1000
+ fetched: true,
1001
+ notModified: false,
1002
+ parsed: items.length,
1003
+ matched,
1004
+ recorded,
1005
+ deduped,
1006
+ upstreamStatus: 200,
1007
+ etag: result.etag,
1008
+ error: ""
1009
+ };
1010
+ }
1011
+ async function poll(params, rpc) {
1012
+ const sourceId = params.sourceId;
1013
+ const blockedGroups = parseCommaList(state.blockedGroupsCsv);
1014
+ let parsed = 0;
1015
+ let matched = 0;
1016
+ let recorded = 0;
1017
+ let deduped = 0;
1018
+ let worstStatus = 200;
1019
+ let lastEtag = null;
1020
+ let seenSeries = 0;
1021
+ let skippedNoMuId = 0;
1022
+ for await (const entry of iterateTrackedSeries(rpc, sourceId)) {
1023
+ seenSeries++;
1024
+ const outcome = await pollSeries(rpc, sourceId, entry, {
1025
+ blockedGroups,
1026
+ timeoutMs: state.requestTimeoutMs
1027
+ });
1028
+ parsed += outcome.parsed;
1029
+ matched += outcome.matched;
1030
+ recorded += outcome.recorded;
1031
+ deduped += outcome.deduped;
1032
+ if (outcome.upstreamStatus > worstStatus) {
1033
+ worstStatus = outcome.upstreamStatus;
1034
+ }
1035
+ if (outcome.etag) lastEtag = outcome.etag;
1036
+ if (outcome.error === "missing mangaupdates external ID") {
1037
+ skippedNoMuId++;
1038
+ } else if (outcome.error) {
1039
+ logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);
1040
+ }
1041
+ }
1042
+ if (skippedNoMuId > 0) {
1043
+ logger.info(
1044
+ `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`
1045
+ );
1046
+ }
1047
+ logger.info(
1048
+ `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`
1049
+ );
1050
+ return {
1051
+ notModified: false,
1052
+ upstreamStatus: worstStatus,
1053
+ parsed,
1054
+ matched,
1055
+ recorded,
1056
+ deduped,
1057
+ ...lastEtag !== null ? { etag: lastEtag } : {}
1058
+ };
1059
+ }
1060
+ async function registerSources(rpc) {
1061
+ const sources = [
1062
+ {
1063
+ sourceKey: "default",
1064
+ displayName: "MangaUpdates Releases",
1065
+ kind: "rss-series",
1066
+ config: null
1067
+ }
1068
+ ];
1069
+ const maxAttempts = 5;
1070
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1071
+ try {
1072
+ return await rpc.call(
1073
+ RELEASES_METHODS.REGISTER_SOURCES,
1074
+ { sources }
1075
+ );
1076
+ } catch (err) {
1077
+ const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;
1078
+ if (isMethodNotFound && attempt < maxAttempts) {
1079
+ await new Promise((r) => setTimeout(r, 50 * attempt));
1080
+ continue;
1081
+ }
1082
+ const reason = err instanceof Error ? err.message : String(err);
1083
+ logger.error(`register_sources failed: ${reason}`);
1084
+ return null;
1085
+ }
1086
+ }
1087
+ return null;
1088
+ }
1089
+ createReleaseSourcePlugin({
1090
+ manifest,
1091
+ provider: {
1092
+ async poll(params) {
1093
+ if (!state.hostRpc) {
1094
+ throw new Error("Plugin not initialized: hostRpc client missing");
1095
+ }
1096
+ return poll(params, state.hostRpc);
1097
+ }
1098
+ },
1099
+ logLevel: "info",
1100
+ async onInitialize(params) {
1101
+ state.hostRpc = params.hostRpc;
1102
+ const ac = params.adminConfig ?? {};
1103
+ if (typeof ac.blockedGroups === "string") {
1104
+ state.blockedGroupsCsv = ac.blockedGroups;
1105
+ }
1106
+ if (typeof ac.requestTimeoutMs === "number" && Number.isFinite(ac.requestTimeoutMs)) {
1107
+ state.requestTimeoutMs = Math.max(1e3, Math.min(ac.requestTimeoutMs, 6e4));
1108
+ }
1109
+ logger.info(
1110
+ `initialized: blockedGroups=${state.blockedGroupsCsv ? "set" : "empty"} timeoutMs=${state.requestTimeoutMs}`
1111
+ );
1112
+ queueMicrotask(() => {
1113
+ void registerSources(params.hostRpc).then((result) => {
1114
+ if (result) {
1115
+ logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);
1116
+ }
1117
+ });
1118
+ });
1119
+ }
1120
+ });
1121
+ logger.info("MangaUpdates release-source plugin started");
1122
+ export {
1123
+ _resetState,
1124
+ pollSeries,
1125
+ registerSources
1126
+ };
1127
+ //# sourceMappingURL=index.js.map