@ashdev/codex-plugin-sync-anilist 1.10.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,1095 @@
1
+ #!/usr/bin/env node
2
+
3
+ // node_modules/@ashdev/codex-plugin-sdk/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
+ var PLUGIN_ERROR_CODES = {
17
+ /** Rate limited by external API */
18
+ RATE_LIMITED: -32001,
19
+ /** Resource not found (e.g., series ID doesn't exist) */
20
+ NOT_FOUND: -32002,
21
+ /** Authentication failed (invalid credentials) */
22
+ AUTH_FAILED: -32003,
23
+ /** External API error */
24
+ API_ERROR: -32004,
25
+ /** Plugin configuration error */
26
+ CONFIG_ERROR: -32005
27
+ };
28
+
29
+ // node_modules/@ashdev/codex-plugin-sdk/dist/errors.js
30
+ var PluginError = class extends Error {
31
+ data;
32
+ constructor(message, data) {
33
+ super(message);
34
+ this.name = this.constructor.name;
35
+ this.data = data;
36
+ }
37
+ /**
38
+ * Convert to JSON-RPC error format
39
+ */
40
+ toJsonRpcError() {
41
+ return {
42
+ code: this.code,
43
+ message: this.message,
44
+ data: this.data
45
+ };
46
+ }
47
+ };
48
+ var RateLimitError = class extends PluginError {
49
+ code = PLUGIN_ERROR_CODES.RATE_LIMITED;
50
+ /** Seconds to wait before retrying */
51
+ retryAfterSeconds;
52
+ constructor(retryAfterSeconds, message) {
53
+ super(message ?? `Rate limited, retry after ${retryAfterSeconds}s`, {
54
+ retryAfterSeconds
55
+ });
56
+ this.retryAfterSeconds = retryAfterSeconds;
57
+ }
58
+ };
59
+ var AuthError = class extends PluginError {
60
+ code = PLUGIN_ERROR_CODES.AUTH_FAILED;
61
+ constructor(message) {
62
+ super(message ?? "Authentication failed");
63
+ }
64
+ };
65
+ var ApiError = class extends PluginError {
66
+ code = PLUGIN_ERROR_CODES.API_ERROR;
67
+ statusCode;
68
+ constructor(message, statusCode) {
69
+ super(message, statusCode !== void 0 ? { statusCode } : void 0);
70
+ this.statusCode = statusCode;
71
+ }
72
+ };
73
+
74
+ // node_modules/@ashdev/codex-plugin-sdk/dist/logger.js
75
+ var LOG_LEVELS = {
76
+ debug: 0,
77
+ info: 1,
78
+ warn: 2,
79
+ error: 3
80
+ };
81
+ var Logger = class {
82
+ name;
83
+ minLevel;
84
+ timestamps;
85
+ constructor(options) {
86
+ this.name = options.name;
87
+ this.minLevel = LOG_LEVELS[options.level ?? "info"];
88
+ this.timestamps = options.timestamps ?? true;
89
+ }
90
+ shouldLog(level) {
91
+ return LOG_LEVELS[level] >= this.minLevel;
92
+ }
93
+ format(level, message, data) {
94
+ const parts = [];
95
+ if (this.timestamps) {
96
+ parts.push((/* @__PURE__ */ new Date()).toISOString());
97
+ }
98
+ parts.push(`[${level.toUpperCase()}]`);
99
+ parts.push(`[${this.name}]`);
100
+ parts.push(message);
101
+ if (data !== void 0) {
102
+ if (data instanceof Error) {
103
+ parts.push(`- ${data.message}`);
104
+ if (data.stack) {
105
+ parts.push(`
106
+ ${data.stack}`);
107
+ }
108
+ } else if (typeof data === "object") {
109
+ parts.push(`- ${JSON.stringify(data)}`);
110
+ } else {
111
+ parts.push(`- ${String(data)}`);
112
+ }
113
+ }
114
+ return parts.join(" ");
115
+ }
116
+ log(level, message, data) {
117
+ if (this.shouldLog(level)) {
118
+ process.stderr.write(`${this.format(level, message, data)}
119
+ `);
120
+ }
121
+ }
122
+ debug(message, data) {
123
+ this.log("debug", message, data);
124
+ }
125
+ info(message, data) {
126
+ this.log("info", message, data);
127
+ }
128
+ warn(message, data) {
129
+ this.log("warn", message, data);
130
+ }
131
+ error(message, data) {
132
+ this.log("error", message, data);
133
+ }
134
+ };
135
+ function createLogger(options) {
136
+ return new Logger(options);
137
+ }
138
+
139
+ // node_modules/@ashdev/codex-plugin-sdk/dist/server.js
140
+ import { createInterface } from "node:readline";
141
+
142
+ // node_modules/@ashdev/codex-plugin-sdk/dist/storage.js
143
+ var StorageError = class extends Error {
144
+ code;
145
+ data;
146
+ constructor(message, code, data) {
147
+ super(message);
148
+ this.code = code;
149
+ this.data = data;
150
+ this.name = "StorageError";
151
+ }
152
+ };
153
+ var PluginStorage = class {
154
+ nextId = 1;
155
+ pendingRequests = /* @__PURE__ */ new Map();
156
+ writeFn;
157
+ /**
158
+ * Create a new storage client.
159
+ *
160
+ * @param writeFn - Optional custom write function (defaults to process.stdout.write).
161
+ * Useful for testing or custom transport layers.
162
+ */
163
+ constructor(writeFn) {
164
+ this.writeFn = writeFn ?? ((line) => {
165
+ process.stdout.write(line);
166
+ });
167
+ }
168
+ /**
169
+ * Get a value by key
170
+ *
171
+ * @param key - Storage key to retrieve
172
+ * @returns The stored data and optional expiration, or null data if key doesn't exist
173
+ */
174
+ async get(key) {
175
+ return await this.sendRequest("storage/get", { key });
176
+ }
177
+ /**
178
+ * Set a value by key (upsert - creates or updates)
179
+ *
180
+ * @param key - Storage key
181
+ * @param data - JSON-serializable data to store
182
+ * @param expiresAt - Optional expiration timestamp (ISO 8601)
183
+ * @returns Success indicator
184
+ */
185
+ async set(key, data, expiresAt) {
186
+ const params = { key, data };
187
+ if (expiresAt !== void 0) {
188
+ params.expiresAt = expiresAt;
189
+ }
190
+ return await this.sendRequest("storage/set", params);
191
+ }
192
+ /**
193
+ * Delete a value by key
194
+ *
195
+ * @param key - Storage key to delete
196
+ * @returns Whether the key existed and was deleted
197
+ */
198
+ async delete(key) {
199
+ return await this.sendRequest("storage/delete", { key });
200
+ }
201
+ /**
202
+ * List all keys for this plugin instance (excluding expired)
203
+ *
204
+ * @returns List of key entries with metadata
205
+ */
206
+ async list() {
207
+ return await this.sendRequest("storage/list", {});
208
+ }
209
+ /**
210
+ * Clear all data for this plugin instance
211
+ *
212
+ * @returns Number of entries deleted
213
+ */
214
+ async clear() {
215
+ return await this.sendRequest("storage/clear", {});
216
+ }
217
+ /**
218
+ * Handle an incoming JSON-RPC response line from the host.
219
+ *
220
+ * Call this method from your readline handler to deliver responses
221
+ * back to pending storage requests.
222
+ */
223
+ handleResponse(line) {
224
+ const trimmed = line.trim();
225
+ if (!trimmed)
226
+ return;
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(trimmed);
230
+ } catch {
231
+ return;
232
+ }
233
+ const obj = parsed;
234
+ if (obj.method !== void 0) {
235
+ return;
236
+ }
237
+ const id = obj.id;
238
+ if (id === void 0 || id === null)
239
+ return;
240
+ const pending = this.pendingRequests.get(id);
241
+ if (!pending)
242
+ return;
243
+ this.pendingRequests.delete(id);
244
+ if ("error" in obj && obj.error) {
245
+ const err = obj.error;
246
+ pending.reject(new StorageError(err.message, err.code, err.data));
247
+ } else {
248
+ pending.resolve(obj.result);
249
+ }
250
+ }
251
+ /**
252
+ * Cancel all pending requests (e.g. on shutdown).
253
+ */
254
+ cancelAll() {
255
+ for (const [, pending] of this.pendingRequests) {
256
+ pending.reject(new StorageError("Storage client stopped", -1));
257
+ }
258
+ this.pendingRequests.clear();
259
+ }
260
+ // ===========================================================================
261
+ // Internal
262
+ // ===========================================================================
263
+ sendRequest(method, params) {
264
+ const id = this.nextId++;
265
+ const request = {
266
+ jsonrpc: "2.0",
267
+ id,
268
+ method,
269
+ params
270
+ };
271
+ return new Promise((resolve, reject) => {
272
+ this.pendingRequests.set(id, { resolve, reject });
273
+ try {
274
+ this.writeFn(`${JSON.stringify(request)}
275
+ `);
276
+ } catch (err) {
277
+ this.pendingRequests.delete(id);
278
+ const message = err instanceof Error ? err.message : "Unknown write error";
279
+ reject(new StorageError(`Failed to send request: ${message}`, -1));
280
+ }
281
+ });
282
+ }
283
+ };
284
+
285
+ // node_modules/@ashdev/codex-plugin-sdk/dist/server.js
286
+ function createPluginServer(options) {
287
+ const { manifest: manifest2, onInitialize, logLevel = "info", label, router } = options;
288
+ const logger2 = createLogger({ name: manifest2.name, level: logLevel });
289
+ const prefix = label ? `${label} plugin` : "plugin";
290
+ const storage = new PluginStorage();
291
+ logger2.info(`Starting ${prefix}: ${manifest2.displayName} v${manifest2.version}`);
292
+ const rl = createInterface({
293
+ input: process.stdin,
294
+ terminal: false
295
+ });
296
+ rl.on("line", (line) => {
297
+ void handleLine(line, manifest2, onInitialize, router, logger2, storage);
298
+ });
299
+ rl.on("close", () => {
300
+ logger2.info("stdin closed, shutting down");
301
+ storage.cancelAll();
302
+ process.exit(0);
303
+ });
304
+ process.on("uncaughtException", (error) => {
305
+ logger2.error("Uncaught exception", error);
306
+ process.exit(1);
307
+ });
308
+ process.on("unhandledRejection", (reason) => {
309
+ logger2.error("Unhandled rejection", reason);
310
+ });
311
+ }
312
+ function isJsonRpcResponse(obj) {
313
+ if (obj.method !== void 0)
314
+ return false;
315
+ if (obj.id === void 0 || obj.id === null)
316
+ return false;
317
+ return "result" in obj || "error" in obj;
318
+ }
319
+ async function handleLine(line, manifest2, onInitialize, router, logger2, storage) {
320
+ const trimmed = line.trim();
321
+ if (!trimmed)
322
+ return;
323
+ let parsed;
324
+ try {
325
+ parsed = JSON.parse(trimmed);
326
+ } catch {
327
+ }
328
+ if (parsed && isJsonRpcResponse(parsed)) {
329
+ logger2.debug("Routing storage response", { id: parsed.id });
330
+ storage.handleResponse(trimmed);
331
+ return;
332
+ }
333
+ let id = null;
334
+ try {
335
+ const request = parsed ?? JSON.parse(trimmed);
336
+ id = request.id;
337
+ logger2.debug(`Received request: ${request.method}`, { id: request.id });
338
+ const response = await handleRequest(request, manifest2, onInitialize, router, logger2, storage);
339
+ if (response !== null) {
340
+ writeResponse(response);
341
+ }
342
+ } catch (error) {
343
+ if (error instanceof SyntaxError) {
344
+ writeResponse({
345
+ jsonrpc: "2.0",
346
+ id: null,
347
+ error: {
348
+ code: JSON_RPC_ERROR_CODES.PARSE_ERROR,
349
+ message: "Parse error: invalid JSON"
350
+ }
351
+ });
352
+ } else if (error instanceof PluginError) {
353
+ writeResponse({
354
+ jsonrpc: "2.0",
355
+ id,
356
+ error: error.toJsonRpcError()
357
+ });
358
+ } else {
359
+ const message = error instanceof Error ? error.message : "Unknown error";
360
+ logger2.error("Request failed", error);
361
+ writeResponse({
362
+ jsonrpc: "2.0",
363
+ id,
364
+ error: {
365
+ code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
366
+ message
367
+ }
368
+ });
369
+ }
370
+ }
371
+ }
372
+ async function handleRequest(request, manifest2, onInitialize, router, logger2, storage) {
373
+ const { method, params, id } = request;
374
+ switch (method) {
375
+ case "initialize": {
376
+ const initParams = params ?? {};
377
+ initParams.storage = storage;
378
+ if (onInitialize) {
379
+ await onInitialize(initParams);
380
+ }
381
+ return { jsonrpc: "2.0", id, result: manifest2 };
382
+ }
383
+ case "ping":
384
+ return { jsonrpc: "2.0", id, result: "pong" };
385
+ case "shutdown": {
386
+ logger2.info("Shutdown requested");
387
+ storage.cancelAll();
388
+ const response2 = { jsonrpc: "2.0", id, result: null };
389
+ process.stdout.write(`${JSON.stringify(response2)}
390
+ `, () => {
391
+ process.exit(0);
392
+ });
393
+ return null;
394
+ }
395
+ }
396
+ const response = await router(method, params, id);
397
+ if (response !== null) {
398
+ return response;
399
+ }
400
+ return {
401
+ jsonrpc: "2.0",
402
+ id,
403
+ error: {
404
+ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
405
+ message: `Method not found: ${method}`
406
+ }
407
+ };
408
+ }
409
+ function writeResponse(response) {
410
+ process.stdout.write(`${JSON.stringify(response)}
411
+ `);
412
+ }
413
+ function methodNotFound(id, message) {
414
+ return {
415
+ jsonrpc: "2.0",
416
+ id,
417
+ error: {
418
+ code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,
419
+ message
420
+ }
421
+ };
422
+ }
423
+ function success(id, result) {
424
+ return { jsonrpc: "2.0", id, result };
425
+ }
426
+ function createSyncPlugin(options) {
427
+ const { manifest: manifest2, provider: provider2, onInitialize, logLevel } = options;
428
+ const router = async (method, params, id) => {
429
+ switch (method) {
430
+ case "sync/getUserInfo":
431
+ return success(id, await provider2.getUserInfo());
432
+ case "sync/pushProgress":
433
+ return success(id, await provider2.pushProgress(params));
434
+ case "sync/pullProgress":
435
+ return success(id, await provider2.pullProgress(params));
436
+ case "sync/status": {
437
+ if (!provider2.status)
438
+ return methodNotFound(id, "This plugin does not support sync/status");
439
+ return success(id, await provider2.status());
440
+ }
441
+ default:
442
+ return null;
443
+ }
444
+ };
445
+ createPluginServer({ manifest: manifest2, onInitialize, logLevel, label: "sync", router });
446
+ }
447
+
448
+ // node_modules/@ashdev/codex-plugin-sdk/dist/types/manifest.js
449
+ var EXTERNAL_ID_SOURCE_ANILIST = "api:anilist";
450
+
451
+ // src/anilist.ts
452
+ var ANILIST_API_URL = "https://graphql.anilist.co";
453
+ var VIEWER_QUERY = `
454
+ query {
455
+ Viewer {
456
+ id
457
+ name
458
+ avatar {
459
+ large
460
+ medium
461
+ }
462
+ siteUrl
463
+ options {
464
+ displayAdultContent
465
+ }
466
+ mediaListOptions {
467
+ scoreFormat
468
+ }
469
+ }
470
+ }
471
+ `;
472
+ var MANGA_LIST_QUERY = `
473
+ query ($userId: Int!, $page: Int, $perPage: Int) {
474
+ Page(page: $page, perPage: $perPage) {
475
+ pageInfo {
476
+ total
477
+ currentPage
478
+ lastPage
479
+ hasNextPage
480
+ }
481
+ mediaList(userId: $userId, type: MANGA, sort: UPDATED_TIME_DESC) {
482
+ id
483
+ mediaId
484
+ status
485
+ score
486
+ progress
487
+ progressVolumes
488
+ startedAt {
489
+ year
490
+ month
491
+ day
492
+ }
493
+ completedAt {
494
+ year
495
+ month
496
+ day
497
+ }
498
+ notes
499
+ updatedAt
500
+ media {
501
+ id
502
+ title {
503
+ romaji
504
+ english
505
+ native
506
+ }
507
+ siteUrl
508
+ }
509
+ }
510
+ }
511
+ }
512
+ `;
513
+ var SEARCH_MANGA_QUERY = `
514
+ query ($search: String!) {
515
+ Media(search: $search, type: MANGA) {
516
+ id
517
+ title {
518
+ romaji
519
+ english
520
+ }
521
+ }
522
+ }
523
+ `;
524
+ var UPDATE_ENTRY_MUTATION = `
525
+ mutation (
526
+ $mediaId: Int!,
527
+ $status: MediaListStatus,
528
+ $score: Float,
529
+ $progress: Int,
530
+ $progressVolumes: Int,
531
+ $startedAt: FuzzyDateInput,
532
+ $completedAt: FuzzyDateInput,
533
+ $notes: String
534
+ ) {
535
+ SaveMediaListEntry(
536
+ mediaId: $mediaId,
537
+ status: $status,
538
+ score: $score,
539
+ progress: $progress,
540
+ progressVolumes: $progressVolumes,
541
+ startedAt: $startedAt,
542
+ completedAt: $completedAt,
543
+ notes: $notes
544
+ ) {
545
+ id
546
+ mediaId
547
+ status
548
+ score
549
+ progress
550
+ progressVolumes
551
+ }
552
+ }
553
+ `;
554
+ var AniListClient = class {
555
+ accessToken;
556
+ constructor(accessToken) {
557
+ this.accessToken = accessToken;
558
+ }
559
+ /**
560
+ * Execute a GraphQL query against the AniList API.
561
+ * On rate limit (429), waits the requested duration and retries once.
562
+ */
563
+ async query(queryStr, variables) {
564
+ return this.executeQuery(queryStr, variables, true);
565
+ }
566
+ async executeQuery(queryStr, variables, allowRetry) {
567
+ let response;
568
+ try {
569
+ response = await fetch(ANILIST_API_URL, {
570
+ method: "POST",
571
+ signal: AbortSignal.timeout(3e4),
572
+ headers: {
573
+ "Content-Type": "application/json",
574
+ Accept: "application/json",
575
+ Authorization: `Bearer ${this.accessToken}`
576
+ },
577
+ body: JSON.stringify({ query: queryStr, variables })
578
+ });
579
+ } catch (error) {
580
+ if (error instanceof DOMException && error.name === "TimeoutError") {
581
+ throw new ApiError("AniList API request timed out after 30 seconds");
582
+ }
583
+ throw error;
584
+ }
585
+ if (response.status === 401) {
586
+ throw new AuthError("AniList access token is invalid or expired");
587
+ }
588
+ if (response.status === 429) {
589
+ const retryAfter = response.headers.get("Retry-After");
590
+ const retrySeconds = retryAfter ? Number.parseInt(retryAfter, 10) : 60;
591
+ const waitSeconds = Number.isNaN(retrySeconds) ? 60 : retrySeconds;
592
+ if (allowRetry) {
593
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
594
+ return this.executeQuery(queryStr, variables, false);
595
+ }
596
+ throw new RateLimitError(waitSeconds, "AniList rate limit exceeded");
597
+ }
598
+ if (!response.ok) {
599
+ const body = await response.text().catch(() => "");
600
+ throw new ApiError(
601
+ `AniList API error: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`
602
+ );
603
+ }
604
+ const json = await response.json();
605
+ if (json.errors?.length) {
606
+ const message = json.errors.map((e) => e.message).join("; ");
607
+ throw new ApiError(`AniList GraphQL error: ${message}`);
608
+ }
609
+ if (!json.data) {
610
+ throw new ApiError("AniList returned empty data");
611
+ }
612
+ return json.data;
613
+ }
614
+ /**
615
+ * Get the authenticated user's info
616
+ */
617
+ async getViewer() {
618
+ const data = await this.query(VIEWER_QUERY);
619
+ return data.Viewer;
620
+ }
621
+ /**
622
+ * Get the user's manga list (paginated)
623
+ */
624
+ async getMangaList(userId, page = 1, perPage = 50) {
625
+ const variables = { userId, page, perPage };
626
+ const data = await this.query(MANGA_LIST_QUERY, variables);
627
+ return {
628
+ pageInfo: data.Page.pageInfo,
629
+ entries: data.Page.mediaList
630
+ };
631
+ }
632
+ /**
633
+ * Update or create a manga list entry
634
+ */
635
+ async saveEntry(variables) {
636
+ const data = await this.query(
637
+ UPDATE_ENTRY_MUTATION,
638
+ variables
639
+ );
640
+ return data.SaveMediaListEntry;
641
+ }
642
+ /**
643
+ * Search for a manga by title and return its AniList ID.
644
+ * Returns null if no result found or an error occurs.
645
+ */
646
+ async searchManga(title) {
647
+ try {
648
+ const data = await this.query(SEARCH_MANGA_QUERY, {
649
+ search: title
650
+ });
651
+ return data.Media;
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+ };
657
+ function anilistStatusToSync(status) {
658
+ switch (status) {
659
+ case "CURRENT":
660
+ case "REPEATING":
661
+ return "reading";
662
+ case "COMPLETED":
663
+ return "completed";
664
+ case "PAUSED":
665
+ return "on_hold";
666
+ case "DROPPED":
667
+ return "dropped";
668
+ case "PLANNING":
669
+ return "plan_to_read";
670
+ default:
671
+ return "reading";
672
+ }
673
+ }
674
+ function syncStatusToAnilist(status) {
675
+ switch (status) {
676
+ case "reading":
677
+ return "CURRENT";
678
+ case "completed":
679
+ return "COMPLETED";
680
+ case "on_hold":
681
+ return "PAUSED";
682
+ case "dropped":
683
+ return "DROPPED";
684
+ case "plan_to_read":
685
+ return "PLANNING";
686
+ default:
687
+ return "CURRENT";
688
+ }
689
+ }
690
+ function fuzzyDateToIso(date) {
691
+ if (!date?.year) return void 0;
692
+ const month = date.month ? String(date.month).padStart(2, "0") : "01";
693
+ const day = date.day ? String(date.day).padStart(2, "0") : "01";
694
+ return `${date.year}-${month}-${day}T00:00:00Z`;
695
+ }
696
+ function isoToFuzzyDate(iso) {
697
+ if (!iso) return void 0;
698
+ const d = new Date(iso);
699
+ if (Number.isNaN(d.getTime())) return void 0;
700
+ return {
701
+ year: d.getUTCFullYear(),
702
+ month: d.getUTCMonth() + 1,
703
+ day: d.getUTCDate()
704
+ };
705
+ }
706
+ function convertScoreToAnilist(score, format) {
707
+ switch (format) {
708
+ case "POINT_100":
709
+ return Math.round(score);
710
+ case "POINT_10_DECIMAL":
711
+ return score / 10;
712
+ case "POINT_10":
713
+ return Math.round(score / 10);
714
+ case "POINT_5":
715
+ return Math.round(score / 20);
716
+ case "POINT_3":
717
+ if (score >= 70) return 3;
718
+ if (score >= 40) return 2;
719
+ return 1;
720
+ default:
721
+ return Math.round(score / 10);
722
+ }
723
+ }
724
+ function convertScoreFromAnilist(score, format) {
725
+ switch (format) {
726
+ case "POINT_100":
727
+ return score;
728
+ case "POINT_10_DECIMAL":
729
+ return score * 10;
730
+ case "POINT_10":
731
+ return score * 10;
732
+ case "POINT_5":
733
+ return score * 20;
734
+ case "POINT_3":
735
+ return Math.round(score * 33.3);
736
+ default:
737
+ return score * 10;
738
+ }
739
+ }
740
+
741
+ // package.json
742
+ var package_default = {
743
+ name: "@ashdev/codex-plugin-sync-anilist",
744
+ version: "1.10.0",
745
+ description: "AniList reading progress sync plugin for Codex",
746
+ main: "dist/index.js",
747
+ bin: "dist/index.js",
748
+ type: "module",
749
+ files: [
750
+ "dist",
751
+ "README.md"
752
+ ],
753
+ repository: {
754
+ type: "git",
755
+ url: "https://github.com/AshDevFr/codex.git",
756
+ directory: "plugins/sync-anilist"
757
+ },
758
+ scripts: {
759
+ build: "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'",
760
+ dev: "npm run build -- --watch",
761
+ clean: "rm -rf dist",
762
+ start: "node dist/index.js",
763
+ lint: "biome check .",
764
+ "lint:fix": "biome check --write .",
765
+ typecheck: "tsc --noEmit",
766
+ test: "vitest run --passWithNoTests",
767
+ "test:watch": "vitest",
768
+ prepublishOnly: "npm run lint && npm run build"
769
+ },
770
+ keywords: [
771
+ "codex",
772
+ "plugin",
773
+ "anilist",
774
+ "sync",
775
+ "manga",
776
+ "reading-progress"
777
+ ],
778
+ author: "Codex",
779
+ license: "MIT",
780
+ engines: {
781
+ node: ">=22.0.0"
782
+ },
783
+ dependencies: {
784
+ "@ashdev/codex-plugin-sdk": "^1.10.0"
785
+ },
786
+ devDependencies: {
787
+ "@biomejs/biome": "^2.3.13",
788
+ "@types/node": "^22.0.0",
789
+ esbuild: "^0.24.0",
790
+ typescript: "^5.7.0",
791
+ vitest: "^3.0.0"
792
+ }
793
+ };
794
+
795
+ // src/manifest.ts
796
+ var manifest = {
797
+ name: "sync-anilist",
798
+ displayName: "AniList Sync",
799
+ version: package_default.version,
800
+ description: "Sync manga reading progress between Codex and AniList. Supports push/pull of reading status, chapters read, scores, and dates.",
801
+ author: "Codex",
802
+ homepage: "https://github.com/AshDevFr/codex",
803
+ protocolVersion: "1.0",
804
+ capabilities: {
805
+ userReadSync: true,
806
+ externalIdSource: EXTERNAL_ID_SOURCE_ANILIST
807
+ },
808
+ requiredCredentials: [
809
+ {
810
+ key: "access_token",
811
+ label: "AniList Access Token",
812
+ description: "OAuth access token for AniList API",
813
+ type: "password",
814
+ required: true,
815
+ sensitive: true
816
+ }
817
+ ],
818
+ userConfigSchema: {
819
+ description: "AniList-specific sync settings",
820
+ fields: [
821
+ {
822
+ key: "progressUnit",
823
+ label: "Progress Unit",
824
+ description: "What each book in Codex represents in AniList. Use 'volumes' for manga volumes, 'chapters' for individual chapters",
825
+ type: "string",
826
+ required: false,
827
+ default: "volumes"
828
+ },
829
+ {
830
+ key: "pauseAfterDays",
831
+ label: "Auto-Pause After Days",
832
+ description: "Automatically set in-progress series to Paused on AniList if no reading activity in this many days. Set to 0 to disable.",
833
+ type: "number",
834
+ required: false,
835
+ default: 0
836
+ },
837
+ {
838
+ key: "dropAfterDays",
839
+ label: "Auto-Drop After Days",
840
+ description: "Automatically set in-progress series to Dropped on AniList if no reading activity in this many days. Set to 0 to disable. When both pause and drop are set, the shorter threshold fires first.",
841
+ type: "number",
842
+ required: false,
843
+ default: 0
844
+ },
845
+ {
846
+ key: "searchFallback",
847
+ label: "Search Fallback",
848
+ description: "When a series has no AniList ID, search by title to find a match and sync progress. Disable for strict matching only.",
849
+ type: "boolean",
850
+ required: false,
851
+ default: false
852
+ }
853
+ ]
854
+ },
855
+ oauth: {
856
+ authorizationUrl: "https://anilist.co/api/v2/oauth/authorize",
857
+ tokenUrl: "https://anilist.co/api/v2/oauth/token",
858
+ scopes: [],
859
+ pkce: false
860
+ },
861
+ userDescription: "Sync manga reading progress between Codex and AniList",
862
+ adminSetupInstructions: "To enable OAuth login, create an AniList API client at https://anilist.co/settings/developer. Set the redirect URL to {your-codex-url}/api/v1/user/plugins/oauth/callback. Enter the Client ID below. Without OAuth configured, users can still connect by pasting a personal access token.",
863
+ userSetupInstructions: "Connect your AniList account via OAuth, or paste a personal access token. To generate a token, visit https://anilist.co/settings/developer, create a client with redirect URL https://anilist.co/api/v2/oauth/pin, then authorize it to receive your token."
864
+ };
865
+
866
+ // src/index.ts
867
+ var logger = createLogger({ name: "sync-anilist", level: "debug" });
868
+ var client = null;
869
+ var viewerId = null;
870
+ var scoreFormat = "POINT_10";
871
+ var progressUnit = "volumes";
872
+ var pauseAfterDays = 0;
873
+ var dropAfterDays = 0;
874
+ var searchFallback = false;
875
+ function setClient(c) {
876
+ client = c;
877
+ }
878
+ function setViewerId(id) {
879
+ viewerId = id;
880
+ }
881
+ function setSearchFallback(enabled) {
882
+ searchFallback = enabled;
883
+ }
884
+ function applyStaleness(status, latestUpdatedAt, pauseDays, dropDays, now) {
885
+ if (status !== "reading") return status;
886
+ if (pauseDays === 0 && dropDays === 0) return status;
887
+ if (!latestUpdatedAt) return status;
888
+ const lastActivity = new Date(latestUpdatedAt).getTime();
889
+ if (Number.isNaN(lastActivity)) return status;
890
+ const currentTime = now ?? Date.now();
891
+ const daysInactive = Math.max(0, (currentTime - lastActivity) / (1e3 * 60 * 60 * 24));
892
+ if (dropDays > 0 && daysInactive >= dropDays) {
893
+ return "dropped";
894
+ }
895
+ if (pauseDays > 0 && daysInactive >= pauseDays) {
896
+ return "on_hold";
897
+ }
898
+ return status;
899
+ }
900
+ var provider = {
901
+ async getUserInfo() {
902
+ if (!client) {
903
+ throw new Error("Plugin not initialized - no AniList client");
904
+ }
905
+ const viewer = await client.getViewer();
906
+ viewerId = viewer.id;
907
+ scoreFormat = viewer.mediaListOptions.scoreFormat;
908
+ logger.info(`Authenticated as ${viewer.name} (id: ${viewer.id}, scoreFormat: ${scoreFormat})`);
909
+ return {
910
+ externalId: String(viewer.id),
911
+ username: viewer.name,
912
+ avatarUrl: viewer.avatar.large || viewer.avatar.medium,
913
+ profileUrl: viewer.siteUrl
914
+ };
915
+ },
916
+ async pushProgress(params) {
917
+ if (!client || viewerId === null) {
918
+ throw new Error("Plugin not initialized - call getUserInfo first");
919
+ }
920
+ const existingMediaIds = /* @__PURE__ */ new Set();
921
+ let page = 1;
922
+ let hasMore = true;
923
+ while (hasMore) {
924
+ const result = await client.getMangaList(viewerId, page, 50);
925
+ for (const entry of result.entries) {
926
+ existingMediaIds.add(entry.mediaId);
927
+ }
928
+ hasMore = result.pageInfo.hasNextPage;
929
+ page++;
930
+ }
931
+ const success2 = [];
932
+ const failed = [];
933
+ for (const entry of params.entries) {
934
+ try {
935
+ let mediaId = Number.parseInt(entry.externalId, 10);
936
+ if (Number.isNaN(mediaId)) {
937
+ if (searchFallback && entry.title) {
938
+ const result2 = await client.searchManga(entry.title);
939
+ if (result2) {
940
+ mediaId = result2.id;
941
+ logger.info(`Search fallback resolved "${entry.title}" \u2192 AniList ID ${mediaId}`);
942
+ }
943
+ }
944
+ if (Number.isNaN(mediaId)) {
945
+ failed.push({
946
+ externalId: entry.externalId,
947
+ status: "failed",
948
+ error: searchFallback ? `No AniList match found for "${entry.title || entry.externalId}"` : `Invalid media ID: ${entry.externalId}`
949
+ });
950
+ continue;
951
+ }
952
+ }
953
+ const effectiveStatus = applyStaleness(
954
+ entry.status,
955
+ entry.latestUpdatedAt,
956
+ pauseAfterDays,
957
+ dropAfterDays
958
+ );
959
+ if (effectiveStatus !== entry.status) {
960
+ logger.debug(
961
+ `Entry ${entry.externalId}: auto-${effectiveStatus === "dropped" ? "dropped" : "paused"} (was ${entry.status})`
962
+ );
963
+ }
964
+ const saveParams = {
965
+ mediaId,
966
+ status: syncStatusToAnilist(effectiveStatus)
967
+ };
968
+ const count = entry.progress?.volumes ?? entry.progress?.chapters;
969
+ if (count !== void 0) {
970
+ if (progressUnit === "chapters") {
971
+ saveParams.progress = count;
972
+ } else {
973
+ saveParams.progressVolumes = count;
974
+ }
975
+ }
976
+ if (entry.score !== void 0) {
977
+ saveParams.score = convertScoreToAnilist(entry.score, scoreFormat);
978
+ }
979
+ if (entry.startedAt) {
980
+ saveParams.startedAt = isoToFuzzyDate(entry.startedAt);
981
+ }
982
+ if (entry.completedAt) {
983
+ saveParams.completedAt = isoToFuzzyDate(entry.completedAt);
984
+ }
985
+ if (entry.notes !== void 0) {
986
+ saveParams.notes = entry.notes;
987
+ }
988
+ const resolvedExternalId = String(mediaId);
989
+ const existed = existingMediaIds.has(mediaId);
990
+ const result = await client.saveEntry(saveParams);
991
+ logger.debug(`Pushed entry ${resolvedExternalId}: status=${result.status}`);
992
+ existingMediaIds.add(mediaId);
993
+ success2.push({
994
+ externalId: resolvedExternalId,
995
+ status: existed ? "updated" : "created"
996
+ });
997
+ } catch (error) {
998
+ const message = error instanceof Error ? error.message : "Unknown error";
999
+ logger.error(`Failed to push entry ${entry.externalId}: ${message}`);
1000
+ failed.push({
1001
+ externalId: entry.externalId,
1002
+ status: "failed",
1003
+ error: message
1004
+ });
1005
+ }
1006
+ }
1007
+ return { success: success2, failed };
1008
+ },
1009
+ async pullProgress(params) {
1010
+ if (!client || viewerId === null) {
1011
+ throw new Error("Plugin not initialized - call getUserInfo first");
1012
+ }
1013
+ const page = params.cursor ? Number.parseInt(params.cursor, 10) : 1;
1014
+ const perPage = params.limit ? Math.min(params.limit, 50) : 50;
1015
+ const result = await client.getMangaList(viewerId, page, perPage);
1016
+ const entries = result.entries.map((entry) => ({
1017
+ externalId: String(entry.mediaId),
1018
+ status: anilistStatusToSync(entry.status),
1019
+ progress: {
1020
+ chapters: entry.progress || void 0,
1021
+ volumes: entry.progressVolumes || void 0
1022
+ },
1023
+ score: entry.score > 0 ? convertScoreFromAnilist(entry.score, scoreFormat) : void 0,
1024
+ startedAt: fuzzyDateToIso(entry.startedAt),
1025
+ completedAt: fuzzyDateToIso(entry.completedAt),
1026
+ notes: entry.notes || void 0
1027
+ }));
1028
+ logger.info(
1029
+ `Pulled ${entries.length} entries (page ${result.pageInfo.currentPage}/${result.pageInfo.lastPage})`
1030
+ );
1031
+ return {
1032
+ entries,
1033
+ nextCursor: result.pageInfo.hasNextPage ? String(result.pageInfo.currentPage + 1) : void 0,
1034
+ hasMore: result.pageInfo.hasNextPage
1035
+ };
1036
+ },
1037
+ async status() {
1038
+ if (!client || viewerId === null) {
1039
+ return {
1040
+ pendingPush: 0,
1041
+ pendingPull: 0,
1042
+ conflicts: 0
1043
+ };
1044
+ }
1045
+ const result = await client.getMangaList(viewerId, 1, 1);
1046
+ return {
1047
+ externalCount: result.pageInfo.total,
1048
+ pendingPush: 0,
1049
+ pendingPull: 0,
1050
+ conflicts: 0
1051
+ };
1052
+ }
1053
+ };
1054
+ createSyncPlugin({
1055
+ manifest,
1056
+ provider,
1057
+ logLevel: "debug",
1058
+ onInitialize(params) {
1059
+ const accessToken = params.credentials?.access_token;
1060
+ if (accessToken) {
1061
+ client = new AniListClient(accessToken);
1062
+ logger.info("AniList client initialized with access token");
1063
+ } else {
1064
+ logger.warn("No access token provided - sync operations will fail");
1065
+ }
1066
+ const uc = params.userConfig;
1067
+ if (uc) {
1068
+ const unit = uc.progressUnit;
1069
+ if (unit === "chapters" || unit === "volumes") {
1070
+ progressUnit = unit;
1071
+ }
1072
+ if (typeof uc.pauseAfterDays === "number" && uc.pauseAfterDays >= 0) {
1073
+ pauseAfterDays = uc.pauseAfterDays;
1074
+ }
1075
+ if (typeof uc.dropAfterDays === "number" && uc.dropAfterDays >= 0) {
1076
+ dropAfterDays = uc.dropAfterDays;
1077
+ }
1078
+ if (typeof uc.searchFallback === "boolean") {
1079
+ searchFallback = uc.searchFallback;
1080
+ }
1081
+ logger.info(
1082
+ `Plugin config: progressUnit=${progressUnit}, pauseAfterDays=${pauseAfterDays}, dropAfterDays=${dropAfterDays}, searchFallback=${searchFallback}`
1083
+ );
1084
+ }
1085
+ }
1086
+ });
1087
+ logger.info("AniList sync plugin started");
1088
+ export {
1089
+ applyStaleness,
1090
+ provider,
1091
+ setClient,
1092
+ setSearchFallback,
1093
+ setViewerId
1094
+ };
1095
+ //# sourceMappingURL=index.js.map