@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/README.md +218 -0
- package/dist/index.js +1095 -0
- package/dist/index.js.map +7 -0
- package/package.json +52 -0
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
|