better ui for anilist entries & fixes
This commit is contained in:
@@ -2,81 +2,192 @@ import { queryOne } from '../../shared/database';
|
||||
|
||||
const USER_DB = 'userdata';
|
||||
|
||||
// Configuración de reintentos
|
||||
const RETRY_CONFIG = {
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 5000,
|
||||
backoffMultiplier: 2
|
||||
};
|
||||
|
||||
// Helper para hacer requests con reintentos y manejo de errores
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
retries = RETRY_CONFIG.maxRetries
|
||||
): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
let delay = RETRY_CONFIG.initialDelay;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Si es rate limit (429), esperamos más tiempo
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
|
||||
|
||||
if (attempt < retries) {
|
||||
console.warn(`Rate limited. Esperando ${waitTime}ms antes de reintentar...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Si es un error de servidor (5xx), reintentamos
|
||||
if (response.status >= 500 && attempt < retries) {
|
||||
console.warn(`Error del servidor (${response.status}). Reintentando en ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt < retries && (
|
||||
error instanceof Error && (
|
||||
error.name === 'AbortError' ||
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('network')
|
||||
)
|
||||
)) {
|
||||
console.warn(`Error de conexión (intento ${attempt + 1}/${retries + 1}). Reintentando en ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Request failed after all retries');
|
||||
}
|
||||
|
||||
export async function getUserAniList(appUserId: number) {
|
||||
const sql = `
|
||||
SELECT access_token, anilist_user_id
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = 'AniList';
|
||||
`;
|
||||
try {
|
||||
const sql = `
|
||||
SELECT access_token, anilist_user_id
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = 'AniList';
|
||||
`;
|
||||
|
||||
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
|
||||
if (!integration) return [];
|
||||
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
|
||||
if (!integration) return [];
|
||||
|
||||
const { access_token, anilist_user_id } = integration;
|
||||
const { access_token, anilist_user_id } = integration;
|
||||
if (!access_token || !anilist_user_id) return [];
|
||||
|
||||
const query = `
|
||||
query ($userId: Int) {
|
||||
anime: MediaListCollection(userId: $userId, type: ANIME) {
|
||||
lists {
|
||||
entries {
|
||||
mediaId
|
||||
status
|
||||
progress
|
||||
score
|
||||
const query = `
|
||||
query ($userId: Int) {
|
||||
anime: MediaListCollection(userId: $userId, type: ANIME) {
|
||||
lists {
|
||||
entries {
|
||||
mediaId
|
||||
status
|
||||
progress
|
||||
score
|
||||
startedAt { year month day }
|
||||
completedAt { year month day }
|
||||
repeat
|
||||
notes
|
||||
private
|
||||
}
|
||||
}
|
||||
}
|
||||
manga: MediaListCollection(userId: $userId, type: MANGA) {
|
||||
lists {
|
||||
entries {
|
||||
mediaId
|
||||
status
|
||||
progress
|
||||
score
|
||||
startedAt { year month day }
|
||||
completedAt { year month day }
|
||||
repeat
|
||||
notes
|
||||
private
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
manga: MediaListCollection(userId: $userId, type: MANGA) {
|
||||
lists {
|
||||
entries {
|
||||
mediaId
|
||||
status
|
||||
progress
|
||||
score
|
||||
}
|
||||
`;
|
||||
|
||||
const res = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { userId: anilist_user_id }
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AniList API error: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json?.errors?.length) {
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
|
||||
const fromFuzzy = (d: any) => {
|
||||
if (!d?.year) return null;
|
||||
const m = String(d.month || 1).padStart(2, '0');
|
||||
const day = String(d.day || 1).padStart(2, '0');
|
||||
return `${d.year}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const list of lists || []) {
|
||||
for (const entry of list.entries || []) {
|
||||
result.push({
|
||||
user_id: appUserId,
|
||||
entry_id: entry.mediaId,
|
||||
source: 'anilist',
|
||||
entry_type: type,
|
||||
status: entry.status,
|
||||
progress: entry.progress || 0,
|
||||
score: entry.score || null,
|
||||
start_date: fromFuzzy(entry.startedAt),
|
||||
end_date: fromFuzzy(entry.completedAt),
|
||||
repeat_count: entry.repeat || 0,
|
||||
notes: entry.notes || null,
|
||||
is_private: entry.private ? 1 : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { userId: anilist_user_id }
|
||||
}),
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const list of lists || []) {
|
||||
for (const entry of list.entries || []) {
|
||||
result.push({
|
||||
user_id: appUserId,
|
||||
entry_id: entry.mediaId,
|
||||
source: 'anilist',
|
||||
entry_type: type,
|
||||
status: entry.status,
|
||||
progress: entry.progress || 0,
|
||||
score: entry.score || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return [
|
||||
...normalize(json?.data?.anime?.lists, 'ANIME'),
|
||||
...normalize(json?.data?.manga?.lists, 'MANGA')
|
||||
];
|
||||
return [
|
||||
...normalize(json?.data?.anime?.lists, 'ANIME'),
|
||||
...normalize(json?.data?.manga?.lists, 'MANGA')
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Error fetching AniList data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAniListEntry(token: string, params: {
|
||||
@@ -84,131 +195,318 @@ export async function updateAniListEntry(token: string, params: {
|
||||
status?: string | null;
|
||||
progress?: number | null;
|
||||
score?: number | null;
|
||||
start_date?: string | null; // YYYY-MM-DD
|
||||
end_date?: string | null; // YYYY-MM-DD
|
||||
repeat_count?: number | null;
|
||||
notes?: string | null;
|
||||
is_private?: boolean | number | null;
|
||||
}) {
|
||||
const mutation = `
|
||||
mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) {
|
||||
SaveMediaListEntry (
|
||||
mediaId: $mediaId,
|
||||
status: $status,
|
||||
progress: $progress,
|
||||
score: $score
|
||||
try {
|
||||
if (!token) throw new Error('AniList token is required');
|
||||
|
||||
const mutation = `
|
||||
mutation (
|
||||
$mediaId: Int,
|
||||
$status: MediaListStatus,
|
||||
$progress: Int,
|
||||
$score: Float,
|
||||
$startedAt: FuzzyDateInput,
|
||||
$completedAt: FuzzyDateInput,
|
||||
$repeat: Int,
|
||||
$notes: String,
|
||||
$private: Boolean
|
||||
) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
SaveMediaListEntry (
|
||||
mediaId: $mediaId,
|
||||
status: $status,
|
||||
progress: $progress,
|
||||
score: $score,
|
||||
startedAt: $startedAt,
|
||||
completedAt: $completedAt,
|
||||
repeat: $repeat,
|
||||
notes: $notes,
|
||||
private: $private
|
||||
) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
startedAt { year month day }
|
||||
completedAt { year month day }
|
||||
repeat
|
||||
notes
|
||||
private
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const toFuzzyDate = (dateStr?: string | null) => {
|
||||
if (!dateStr) return null;
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return { year, month, day };
|
||||
};
|
||||
|
||||
const variables: any = {
|
||||
mediaId: Number(params.mediaId),
|
||||
status: params.status ?? undefined,
|
||||
progress: params.progress ?? undefined,
|
||||
score: params.score ?? undefined,
|
||||
startedAt: toFuzzyDate(params.start_date),
|
||||
completedAt: toFuzzyDate(params.end_date),
|
||||
repeat: params.repeat_count ?? undefined,
|
||||
notes: params.notes ?? undefined,
|
||||
private: typeof params.is_private === 'boolean'
|
||||
? params.is_private
|
||||
: params.is_private === 1
|
||||
};
|
||||
|
||||
const res = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: mutation, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`AniList update failed: ${res.status} - ${errorText}`);
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: any = {
|
||||
mediaId: Number(params.mediaId),
|
||||
};
|
||||
const json = await res.json();
|
||||
|
||||
if (params.status != null) variables.status = params.status;
|
||||
if (params.progress != null) variables.progress = params.progress;
|
||||
if (params.score != null) variables.score = params.score;
|
||||
if (json?.errors?.length) {
|
||||
throw new Error(`AniList GraphQL error: ${json.errors[0].message}`);
|
||||
}
|
||||
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: mutation, variables }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || json?.errors?.length) {
|
||||
throw new Error("AniList update failed");
|
||||
return json.data?.SaveMediaListEntry || null;
|
||||
} catch (error) {
|
||||
console.error('Error updating AniList entry:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return json.data?.SaveMediaListEntry || null;
|
||||
}
|
||||
|
||||
export async function deleteAniListEntry(token: string, mediaId: number) {
|
||||
if (!token) throw new Error("AniList token required");
|
||||
|
||||
const query = `
|
||||
query ($mediaId: Int) {
|
||||
MediaList(mediaId: $mediaId) {
|
||||
id
|
||||
}
|
||||
try {
|
||||
// 1️⃣ OBTENER VIEWER
|
||||
const viewerQuery = `query { Viewer { id name } }`;
|
||||
|
||||
const vRes = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: viewerQuery }),
|
||||
});
|
||||
|
||||
const vJson = await vRes.json();
|
||||
const userId = vJson?.data?.Viewer?.id;
|
||||
|
||||
if (!userId) throw new Error("Invalid AniList token");
|
||||
|
||||
// 2️⃣ DETECTAR TIPO REAL DEL MEDIA
|
||||
const mediaQuery = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
id
|
||||
type
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const qRes = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { mediaId } }),
|
||||
});
|
||||
const mTypeRes = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mediaQuery,
|
||||
variables: { id: mediaId }
|
||||
}),
|
||||
});
|
||||
|
||||
const qJson = await qRes.json();
|
||||
const listEntryId = qJson?.data?.MediaList?.id;
|
||||
const mTypeJson = await mTypeRes.json();
|
||||
const mediaType = mTypeJson?.data?.Media?.type;
|
||||
|
||||
if (!listEntryId) {
|
||||
throw new Error("Entry not found or unauthorized to delete.");
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation ($id: Int) {
|
||||
DeleteMediaListEntry(id: $id) {
|
||||
deleted
|
||||
}
|
||||
if (!mediaType) {
|
||||
throw new Error("Media not found in AniList");
|
||||
}
|
||||
|
||||
// 3️⃣ BUSCAR ENTRY CON TIPO REAL
|
||||
const listQuery = `
|
||||
query ($userId: Int, $mediaId: Int, $type: MediaType) {
|
||||
MediaList(userId: $userId, mediaId: $mediaId, type: $type) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mRes = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables: { id: listEntryId }
|
||||
}),
|
||||
});
|
||||
const qRes = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: listQuery,
|
||||
variables: {
|
||||
userId,
|
||||
mediaId,
|
||||
type: mediaType
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const mJson = await mRes.json();
|
||||
const qJson = await qRes.json();
|
||||
const listEntryId = qJson?.data?.MediaList?.id;
|
||||
|
||||
if (mJson?.errors?.length) {
|
||||
throw new Error("Error eliminando entrada en AniList");
|
||||
if (!listEntryId) {
|
||||
throw new Error("Entry not found in user's AniList");
|
||||
}
|
||||
|
||||
// 4️⃣ BORRAR
|
||||
const mutation = `
|
||||
mutation ($id: Int) {
|
||||
DeleteMediaListEntry(id: $id) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const delRes = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables: { id: listEntryId }
|
||||
}),
|
||||
});
|
||||
|
||||
const delJson = await delRes.json();
|
||||
|
||||
if (delJson?.errors?.length) {
|
||||
throw new Error(delJson.errors[0].message);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
console.error("AniList DELETE failed:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export async function getSingleAniListEntry(
|
||||
token: string,
|
||||
mediaId: number,
|
||||
type: 'ANIME' | 'MANGA'
|
||||
) {
|
||||
const query = `
|
||||
query ($mediaId: Int, $type: MediaType) {
|
||||
MediaList(mediaId: $mediaId, type: $type) {
|
||||
status
|
||||
progress
|
||||
score
|
||||
}
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error('AniList token is required');
|
||||
}
|
||||
`;
|
||||
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { mediaId, type }
|
||||
})
|
||||
});
|
||||
// 1️⃣ Obtener userId desde el token
|
||||
const viewerRes = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `query { Viewer { id } }`
|
||||
})
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
return json?.data?.MediaList || null;
|
||||
}
|
||||
const viewerJson = await viewerRes.json();
|
||||
const userId = viewerJson?.data?.Viewer?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Failed to get AniList userId');
|
||||
}
|
||||
|
||||
// 2️⃣ Query correcta con userId
|
||||
const query = `
|
||||
query ($mediaId: Int, $type: MediaType, $userId: Int) {
|
||||
MediaList(mediaId: $mediaId, type: $type, userId: $userId) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
private
|
||||
notes
|
||||
startedAt { year month day }
|
||||
completedAt { year month day }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const res = await fetchWithRetry('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { mediaId, type, userId }
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch entry: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json?.errors?.length) {
|
||||
if (json.errors[0].status === 404) return null;
|
||||
throw new Error(`GraphQL error: ${json.errors[0].message}`);
|
||||
}
|
||||
|
||||
const entry = json?.data?.MediaList;
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
entry_id: mediaId,
|
||||
source: 'anilist',
|
||||
entry_type: type,
|
||||
status: entry.status,
|
||||
progress: entry.progress || 0,
|
||||
score: entry.score ?? null,
|
||||
|
||||
start_date: entry.startedAt?.year
|
||||
? `${entry.startedAt.year}-${String(entry.startedAt.month).padStart(2, '0')}-${String(entry.startedAt.day).padStart(2, '0')}`
|
||||
: null,
|
||||
|
||||
end_date: entry.completedAt?.year
|
||||
? `${entry.completedAt.year}-${String(entry.completedAt.month).padStart(2, '0')}-${String(entry.completedAt.day).padStart(2, '0')}`
|
||||
: null,
|
||||
|
||||
repeat_count: entry.repeat || 0,
|
||||
notes: entry.notes || null,
|
||||
is_private: entry.private ? 1 : 0,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching single AniList entry:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries';
|
||||
import { queryOne, queryAll } from '../../shared/database';
|
||||
import { queryOne, queryAll, run } from '../../shared/database';
|
||||
import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions';
|
||||
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
||||
|
||||
@@ -62,7 +62,10 @@ const MEDIA_FIELDS = `
|
||||
`;
|
||||
|
||||
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
||||
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
|
||||
const row = await queryOne(
|
||||
"SELECT full_data FROM books WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
return JSON.parse(row.full_data);
|
||||
@@ -70,6 +73,7 @@ export async function getBookById(id: string | number): Promise<Book | { error:
|
||||
|
||||
try {
|
||||
console.log(`[Book] Local miss for ID ${id}, fetching live...`);
|
||||
|
||||
const query = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: MANGA) {
|
||||
@@ -90,13 +94,38 @@ export async function getBookById(id: string | number): Promise<Book | { error:
|
||||
|
||||
const response = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ query, variables: { id: parseInt(id.toString()) } })
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { id: parseInt(id.toString()) }
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data && data.data.Media) {
|
||||
return data.data.Media;
|
||||
|
||||
if (data?.data?.Media) {
|
||||
const media = data.data.Media;
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO books (id, title, updatedAt, full_data)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
updatedAt = EXCLUDED.updatedAt,
|
||||
full_data = EXCLUDED.full_data;
|
||||
`;
|
||||
|
||||
await run(insertSql, [
|
||||
media.id,
|
||||
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
|
||||
media.updatedAt || Math.floor(Date.now() / 1000),
|
||||
JSON.stringify(media)
|
||||
]);
|
||||
|
||||
return media;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import * as listService from './list.service';
|
||||
|
||||
interface UserRequest extends FastifyRequest {
|
||||
@@ -53,13 +53,11 @@ export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
|
||||
return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." });
|
||||
}
|
||||
|
||||
const entryIdentifier = entryId;
|
||||
|
||||
try {
|
||||
|
||||
const entry = await listService.getSingleListEntry(
|
||||
userId,
|
||||
entryIdentifier,
|
||||
entryId,
|
||||
source,
|
||||
entry_type
|
||||
);
|
||||
@@ -78,14 +76,16 @@ export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
|
||||
|
||||
export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
|
||||
const userId = req.user?.id;
|
||||
const body = req.body as UpsertEntryBody;
|
||||
const body = req.body as any;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (!body.entry_id || !body.source || !body.status || !body.entry_type) {
|
||||
return reply.code(400).send({ error: "Missing required fields (entry_id, source, status, entry_type)." });
|
||||
return reply.code(400).send({
|
||||
error: "Missing required fields (entry_id, source, status, entry_type)."
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -97,7 +97,12 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
|
||||
entry_type: body.entry_type,
|
||||
status: body.status,
|
||||
progress: body.progress || 0,
|
||||
score: body.score || null
|
||||
score: body.score || null,
|
||||
start_date: body.start_date || null,
|
||||
end_date: body.end_date || null,
|
||||
repeat_count: body.repeat_count ?? 0,
|
||||
notes: body.notes || null,
|
||||
is_private: body.is_private ?? 0
|
||||
};
|
||||
|
||||
const result = await listService.upsertListEntry(entryData);
|
||||
@@ -112,24 +117,29 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
|
||||
export async function deleteEntry(req: UserRequest, reply: FastifyReply) {
|
||||
const userId = req.user?.id;
|
||||
const { entryId } = req.params as EntryParams;
|
||||
const { source } = req.query as { source?: string }; // ✅ VIENE DEL FRONT
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const entryIdentifier = entryId;
|
||||
|
||||
if (!entryIdentifier) {
|
||||
return reply.code(400).send({ error: "Invalid entry ID." });
|
||||
if (!entryId || !source) {
|
||||
return reply.code(400).send({ error: "Missing entryId or source." });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listService.deleteListEntry(userId, entryIdentifier);
|
||||
const result = await listService.deleteListEntry(
|
||||
userId,
|
||||
entryId,
|
||||
source
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: "Entry deleted successfully." };
|
||||
return { success: true, external: result.external };
|
||||
} else {
|
||||
return reply.code(404).send({ error: "Entry not found or unauthorized to delete." });
|
||||
return reply.code(404).send({
|
||||
error: "Entry not found or unauthorized to delete."
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -27,43 +27,60 @@ export async function upsertListEntry(entry: any) {
|
||||
entry_type,
|
||||
status,
|
||||
progress,
|
||||
score
|
||||
score,
|
||||
start_date,
|
||||
end_date,
|
||||
repeat_count,
|
||||
notes,
|
||||
is_private
|
||||
} = entry;
|
||||
|
||||
if (source === 'anilist') {
|
||||
|
||||
const token = await getActiveAccessToken(user_id);
|
||||
|
||||
if (token) {
|
||||
|
||||
try {
|
||||
const result = await aniListService.updateAniListEntry(token, {
|
||||
mediaId: entry_id,
|
||||
status,
|
||||
progress,
|
||||
score
|
||||
score,
|
||||
start_date,
|
||||
end_date,
|
||||
repeat_count,
|
||||
notes,
|
||||
is_private
|
||||
});
|
||||
|
||||
|
||||
return { changes: 0, external: true, anilistResult: result };
|
||||
} catch (err) {
|
||||
console.error("Error actualizando AniList:", err);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ListEntry
|
||||
(user_id, entry_id, source, entry_type, status, progress, score, updated_at)
|
||||
(
|
||||
user_id, entry_id, source, entry_type, status,
|
||||
progress, score,
|
||||
start_date, end_date, repeat_count, notes, is_private,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, entry_id) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
entry_type = EXCLUDED.entry_type,
|
||||
status = EXCLUDED.status,
|
||||
progress = EXCLUDED.progress,
|
||||
score = EXCLUDED.score,
|
||||
start_date = EXCLUDED.start_date,
|
||||
end_date = EXCLUDED.end_date,
|
||||
repeat_count = EXCLUDED.repeat_count,
|
||||
notes = EXCLUDED.notes,
|
||||
is_private = EXCLUDED.is_private,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
`;
|
||||
|
||||
@@ -74,7 +91,12 @@ export async function upsertListEntry(entry: any) {
|
||||
entry_type,
|
||||
status,
|
||||
progress,
|
||||
score || null
|
||||
score ?? null,
|
||||
start_date || null,
|
||||
end_date || null,
|
||||
repeat_count ?? 0,
|
||||
notes || null,
|
||||
is_private ?? 0
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -101,8 +123,6 @@ export async function getUserList(userId: number): Promise<any> {
|
||||
let finalList: ListEntryData[] = [...dbList];
|
||||
|
||||
if (connected) {
|
||||
const token = await getActiveAccessToken(userId);
|
||||
|
||||
const anilistEntries = await aniListService.getUserAniList(userId);
|
||||
|
||||
const localWithoutAnilist = dbList.filter(
|
||||
@@ -184,30 +204,22 @@ export async function getUserList(userId: number): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteListEntry(userId: number, entryId: string | number) {
|
||||
export async function deleteListEntry(
|
||||
userId: number,
|
||||
entryId: string | number,
|
||||
source: string
|
||||
) {
|
||||
|
||||
const checkSql = `
|
||||
SELECT source
|
||||
FROM ListEntry
|
||||
WHERE user_id = ? AND entry_id = ?;
|
||||
`;
|
||||
if (source === 'anilist') {
|
||||
|
||||
const existing = await queryOne(checkSql, [userId, entryId], USER_DB) as any;
|
||||
|
||||
if (existing?.source === 'anilist') {
|
||||
const sql = `
|
||||
SELECT access_token, anilist_user_id
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = 'AniList';
|
||||
`;
|
||||
const token = await getActiveAccessToken(userId);
|
||||
|
||||
const integration = await queryOne(sql, [userId], USER_DB) as any;
|
||||
|
||||
if (integration?.access_token) {
|
||||
if (token) {
|
||||
try {
|
||||
await aniListService.deleteAniListEntry(
|
||||
integration.access_token,
|
||||
Number(entryId)
|
||||
token,
|
||||
Number(entryId),
|
||||
);
|
||||
|
||||
return { success: true, external: true };
|
||||
@@ -217,18 +229,14 @@ export async function deleteListEntry(userId: number, entryId: string | number)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SOLO LOCAL
|
||||
const sql = `
|
||||
DELETE FROM ListEntry
|
||||
WHERE user_id = ? AND entry_id = ?;
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await run(sql, [userId, entryId], USER_DB);
|
||||
return { success: result.changes > 0, changes: result.changes, external: false };
|
||||
} catch (error) {
|
||||
console.error("Error al eliminar la entrada de lista:", error);
|
||||
throw new Error("Error en la base de datos al eliminar la entrada.");
|
||||
}
|
||||
const result = await run(sql, [userId, entryId], USER_DB);
|
||||
return { success: result.changes > 0, changes: result.changes, external: false };
|
||||
}
|
||||
|
||||
export async function getSingleListEntry(
|
||||
@@ -238,19 +246,61 @@ export async function getSingleListEntry(
|
||||
entryType: string
|
||||
): Promise<any> {
|
||||
|
||||
const connected = await isConnected(userId);
|
||||
// ✅ 1. BUSCAR PRIMERO EN TU BASE DE DATOS
|
||||
const localSql = `
|
||||
SELECT * FROM ListEntry
|
||||
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
|
||||
`;
|
||||
|
||||
if (source === 'anilist' && connected) {
|
||||
const localResult = await queryAll(
|
||||
localSql,
|
||||
[userId, entryId, source, entryType],
|
||||
USER_DB
|
||||
) as any[];
|
||||
|
||||
if (localResult.length > 0) {
|
||||
const entry = localResult[0];
|
||||
|
||||
const contentDetails: any =
|
||||
entryType === 'ANIME'
|
||||
? await animeService.getAnimeById(entryId).catch(() => null)
|
||||
: await booksService.getBookById(entryId).catch(() => null);
|
||||
|
||||
let finalTitle = contentDetails?.title || 'Unknown';
|
||||
let finalPoster = contentDetails?.coverImage?.extraLarge ||
|
||||
contentDetails?.image ||
|
||||
'https://placehold.co/400x600?text=No+Cover';
|
||||
|
||||
if (typeof finalTitle === 'object') {
|
||||
finalTitle =
|
||||
finalTitle.userPreferred ||
|
||||
finalTitle.english ||
|
||||
finalTitle.romaji ||
|
||||
'Unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
title: finalTitle,
|
||||
poster: finalPoster,
|
||||
total_episodes: contentDetails?.episodes,
|
||||
total_chapters: contentDetails?.chapters,
|
||||
};
|
||||
}
|
||||
|
||||
if (source === 'anilist') {
|
||||
|
||||
const connected = await isConnected(userId);
|
||||
if (!connected) return null;
|
||||
|
||||
const sql = `
|
||||
SELECT access_token, anilist_user_id
|
||||
SELECT access_token
|
||||
FROM UserIntegration
|
||||
WHERE user_id = ? AND platform = 'AniList';
|
||||
`;
|
||||
|
||||
const integration = await queryOne(sql, [userId], USER_DB) as any;
|
||||
|
||||
if (!integration) return null;
|
||||
if (!integration?.access_token) return null;
|
||||
|
||||
const aniEntry = await aniListService.getSingleAniListEntry(
|
||||
integration.access_token,
|
||||
@@ -258,118 +308,38 @@ export async function getSingleListEntry(
|
||||
entryType as any
|
||||
);
|
||||
|
||||
|
||||
if (!aniEntry) return null;
|
||||
|
||||
let contentDetails: any = null;
|
||||
const contentDetails: any =
|
||||
entryType === 'ANIME'
|
||||
? await animeService.getAnimeById(entryId).catch(() => null)
|
||||
: await booksService.getBookById(entryId).catch(() => null);
|
||||
|
||||
if (entryType === 'ANIME') {
|
||||
const anime:any = await animeService.getAnimeById(entryId);
|
||||
let finalTitle = contentDetails?.title || 'Unknown';
|
||||
let finalPoster = contentDetails?.coverImage?.extraLarge ||
|
||||
contentDetails?.image ||
|
||||
'https://placehold.co/400x600?text=No+Cover';
|
||||
|
||||
contentDetails = {
|
||||
title: anime?.title,
|
||||
poster: anime?.coverImage?.extraLarge || anime?.image || '',
|
||||
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0,
|
||||
};
|
||||
} else {
|
||||
const book: any = await booksService.getBookById(entryId);
|
||||
|
||||
contentDetails = {
|
||||
title: book?.title,
|
||||
poster: book?.coverImage?.extraLarge || book?.image || '',
|
||||
total_chapters: book?.chapters || book?.volumes * 10 || 0,
|
||||
};
|
||||
}
|
||||
|
||||
let finalTitle = contentDetails?.title || 'Unknown Title';
|
||||
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
|
||||
|
||||
if (typeof finalTitle === 'object' && finalTitle !== null) {
|
||||
finalTitle = finalTitle.userPreferred || finalTitle.english || finalTitle.romaji || 'Unknown Title';
|
||||
if (typeof finalTitle === 'object') {
|
||||
finalTitle =
|
||||
finalTitle.userPreferred ||
|
||||
finalTitle.english ||
|
||||
finalTitle.romaji ||
|
||||
'Unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
entry_id: Number(entryId),
|
||||
source: 'anilist',
|
||||
entry_type: entryType,
|
||||
status: aniEntry.status,
|
||||
progress: aniEntry.progress,
|
||||
score: aniEntry.score,
|
||||
...aniEntry,
|
||||
title: finalTitle,
|
||||
poster: finalPoster,
|
||||
total_episodes: contentDetails?.total_episodes,
|
||||
total_chapters: contentDetails?.total_chapters,
|
||||
total_episodes: contentDetails?.episodes,
|
||||
total_chapters: contentDetails?.chapters,
|
||||
};
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT * FROM ListEntry
|
||||
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
|
||||
`;
|
||||
|
||||
const dbEntry = await queryAll(sql, [userId, entryId, source, entryType], USER_DB) as ListEntryData[];
|
||||
|
||||
if (!dbEntry || dbEntry.length === 0) return null;
|
||||
|
||||
const entry = dbEntry[0];
|
||||
|
||||
let contentDetails: any | null = null;
|
||||
|
||||
try {
|
||||
if (entryType === 'ANIME') {
|
||||
let anime: any;
|
||||
|
||||
if (source === 'anilist') {
|
||||
anime = await animeService.getAnimeById(entryId);
|
||||
} else {
|
||||
const ext = getExtension(source);
|
||||
anime = await animeService.getAnimeInfoExtension(ext, entryId.toString());
|
||||
}
|
||||
|
||||
contentDetails = {
|
||||
title: anime?.title,
|
||||
poster: anime?.coverImage?.extraLarge || anime?.image || '',
|
||||
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0,
|
||||
};
|
||||
|
||||
} else {
|
||||
let book: any;
|
||||
|
||||
if (source === 'anilist') {
|
||||
book = await booksService.getBookById(entryId);
|
||||
} else {
|
||||
const ext = getExtension(source);
|
||||
book = await booksService.getBookInfoExtension(ext, entryId.toString());
|
||||
}
|
||||
|
||||
contentDetails = {
|
||||
title: book?.title,
|
||||
poster: book?.coverImage?.extraLarge || book?.image || '',
|
||||
total_chapters: book?.chapters || book?.volumes * 10 || 0,
|
||||
};
|
||||
}
|
||||
|
||||
} catch {
|
||||
contentDetails = {
|
||||
title: 'Unknown',
|
||||
poster: 'https://placehold.co/400x600?text=No+Cover',
|
||||
};
|
||||
}
|
||||
|
||||
let finalTitle = contentDetails?.title || 'Unknown Title';
|
||||
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
|
||||
|
||||
if (typeof finalTitle === 'object' && finalTitle !== null) {
|
||||
finalTitle = finalTitle.userPreferred || finalTitle.english || finalTitle.romaji || 'Unknown Title';
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
title: finalTitle,
|
||||
poster: finalPoster,
|
||||
total_episodes: contentDetails?.total_episodes,
|
||||
total_chapters: contentDetails?.total_chapters,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getActiveAccessToken(userId: number): Promise<string | null> {
|
||||
|
||||
Binary file not shown.
@@ -34,8 +34,8 @@ function getSimpleAuthHeaders() {
|
||||
};
|
||||
}
|
||||
|
||||
// Lógica de chequeo de lista corregida para usar /list/entry/{id} y esperar 'found'
|
||||
async function checkIfInList() {
|
||||
|
||||
const entryId = window.location.pathname.split('/').pop();
|
||||
const source = extensionName || 'anilist';
|
||||
const entryType = 'ANIME';
|
||||
@@ -51,8 +51,7 @@ async function checkIfInList() {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.found && data.entry) {
|
||||
|
||||
if (data.found && data.entry) { // Esperamos 'found'
|
||||
isInList = true;
|
||||
currentListEntry = data.entry;
|
||||
} else {
|
||||
@@ -69,6 +68,8 @@ async function checkIfInList() {
|
||||
|
||||
function updateAddToListButton() {
|
||||
const btn = document.getElementById('add-to-list-btn');
|
||||
if (!btn) return;
|
||||
|
||||
if (isInList) {
|
||||
btn.innerHTML = `
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -87,36 +88,73 @@ function updateAddToListButton() {
|
||||
}
|
||||
}
|
||||
|
||||
// Función openAddToListModal actualizada con todos los campos extendidos
|
||||
function openAddToListModal() {
|
||||
if (isInList) {
|
||||
// Referencias
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const deleteBtn = document.getElementById('modal-delete-btn');
|
||||
|
||||
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING';
|
||||
document.getElementById('modal-progress').value = currentListEntry.progress || 0;
|
||||
document.getElementById('modal-score').value = currentListEntry.score || '';
|
||||
// Mapeo de prefijos (usamos 'entry-' para el HTML del modal extendido)
|
||||
const statusEl = document.getElementById('entry-status');
|
||||
const progressEl = document.getElementById('entry-progress');
|
||||
const scoreEl = document.getElementById('entry-score');
|
||||
const startDateEl = document.getElementById('entry-start-date');
|
||||
const endDateEl = document.getElementById('entry-end-date');
|
||||
const repeatCountEl = document.getElementById('entry-repeat-count');
|
||||
const notesEl = document.getElementById('entry-notes');
|
||||
const privateEl = document.getElementById('entry-is-private');
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Edit List Entry';
|
||||
document.getElementById('modal-delete-btn').style.display = 'block';
|
||||
|
||||
if (isInList && currentListEntry) {
|
||||
statusEl.value = currentListEntry.status || 'PLANNING';
|
||||
progressEl.value = currentListEntry.progress || 0;
|
||||
scoreEl.value = currentListEntry.score || '';
|
||||
|
||||
// Campos extendidos
|
||||
startDateEl.value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : '';
|
||||
endDateEl.value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : '';
|
||||
repeatCountEl.value = currentListEntry.repeat_count || 0;
|
||||
notesEl.value = currentListEntry.notes || '';
|
||||
privateEl.checked = currentListEntry.is_private === true || currentListEntry.is_private === 1;
|
||||
|
||||
modalTitle.textContent = 'Edit List Entry';
|
||||
deleteBtn.style.display = 'block';
|
||||
} else {
|
||||
// Valores por defecto
|
||||
statusEl.value = 'PLANNING';
|
||||
progressEl.value = 0;
|
||||
scoreEl.value = '';
|
||||
startDateEl.value = '';
|
||||
endDateEl.value = '';
|
||||
repeatCountEl.value = 0;
|
||||
notesEl.value = '';
|
||||
privateEl.checked = false;
|
||||
|
||||
document.getElementById('modal-status').value = 'PLANNING';
|
||||
document.getElementById('modal-progress').value = 0;
|
||||
document.getElementById('modal-score').value = '';
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Add to List';
|
||||
document.getElementById('modal-delete-btn').style.display = 'none';
|
||||
modalTitle.textContent = 'Add to List';
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('modal-progress').max = totalEpisodes || 999;
|
||||
document.getElementById('add-list-modal').classList.add('active');}
|
||||
progressEl.max = totalEpisodes || 999;
|
||||
document.getElementById('add-list-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeAddToListModal() {
|
||||
document.getElementById('add-list-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Función saveToList actualizada con todos los campos extendidos
|
||||
async function saveToList() {
|
||||
const status = document.getElementById('modal-status').value;
|
||||
const progress = parseInt(document.getElementById('modal-progress').value) || 0;
|
||||
const score = parseFloat(document.getElementById('modal-score').value) || null;
|
||||
const status = document.getElementById('entry-status').value;
|
||||
const progress = parseInt(document.getElementById('entry-progress').value) || 0;
|
||||
const scoreValue = document.getElementById('entry-score').value;
|
||||
const score = scoreValue ? parseFloat(scoreValue) : null;
|
||||
|
||||
// Nuevos campos
|
||||
const start_date = document.getElementById('entry-start-date').value || null;
|
||||
const end_date = document.getElementById('entry-end-date').value || null;
|
||||
const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0;
|
||||
const notes = document.getElementById('entry-notes').value || null;
|
||||
const is_private = document.getElementById('entry-is-private').checked;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list/entry`, {
|
||||
@@ -128,7 +166,13 @@ async function saveToList() {
|
||||
entry_type: 'ANIME',
|
||||
status: status,
|
||||
progress: progress,
|
||||
score: score
|
||||
score: score,
|
||||
// Campos extendidos
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
repeat_count: repeat_count,
|
||||
notes: notes,
|
||||
is_private: is_private
|
||||
})
|
||||
});
|
||||
|
||||
@@ -136,16 +180,10 @@ async function saveToList() {
|
||||
throw new Error('Failed to save entry');
|
||||
}
|
||||
|
||||
isInList = true;
|
||||
currentListEntry = {
|
||||
entry_id: parseInt(animeId),
|
||||
source: extensionName || 'anilist',
|
||||
entry_type: 'ANIME',
|
||||
const data = await response.json();
|
||||
|
||||
status,
|
||||
progress,
|
||||
score
|
||||
};
|
||||
isInList = true;
|
||||
currentListEntry = data.entry;
|
||||
updateAddToListButton();
|
||||
closeAddToListModal();
|
||||
showNotification(isInList ? 'Updated successfully!' : 'Added to your list!', 'success');
|
||||
@@ -158,8 +196,11 @@ async function saveToList() {
|
||||
async function deleteFromList() {
|
||||
if (!confirm('Remove this anime from your list?')) return;
|
||||
|
||||
const source = extensionName || 'anilist';
|
||||
const entryType = 'ANIME';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list/entry/${animeId}`, {
|
||||
const response = await fetch(`${API_BASE}/list/entry/${animeId}?source=${source}&entry_type=${entryType}`, {
|
||||
method: 'DELETE',
|
||||
headers: getSimpleAuthHeaders()
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function getBookEntryType(bookData) {
|
||||
return (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL';
|
||||
}
|
||||
|
||||
// CORRECCIÓN: Usar el endpoint /list/entry/{id} y esperar 'found'
|
||||
async function checkIfInList() {
|
||||
if (!currentBookData) return;
|
||||
|
||||
@@ -54,6 +55,7 @@ async function checkIfInList() {
|
||||
|
||||
const entryType = getBookEntryType(currentBookData);
|
||||
|
||||
// URL CORRECTA: /list/entry/{id}?source={source}&entry_type={entryType}
|
||||
const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`;
|
||||
|
||||
try {
|
||||
@@ -64,6 +66,7 @@ async function checkIfInList() {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// LÓGICA CORRECTA: Comprobar data.found
|
||||
if (data.found && data.entry) {
|
||||
|
||||
isInList = true;
|
||||
@@ -73,6 +76,11 @@ async function checkIfInList() {
|
||||
currentListEntry = null;
|
||||
}
|
||||
updateAddToListButton();
|
||||
} else if (response.status === 404) {
|
||||
// Manejar 404 como 'no encontrado' si la API lo devuelve así
|
||||
isInList = false;
|
||||
currentListEntry = null;
|
||||
updateAddToListButton();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking single list entry:', error);
|
||||
@@ -103,40 +111,71 @@ function updateAddToListButton() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REFACTORIZADO para usar la estructura del modal completo.
|
||||
* Asume que el HTML usa IDs como 'entry-status', 'entry-progress', 'entry-score', etc.
|
||||
*/
|
||||
function openAddToListModal() {
|
||||
if (!currentBookData) return;
|
||||
|
||||
const totalUnits = currentBookData.chapters || currentBookData.volumes || 999;
|
||||
const entryType = getBookEntryType(currentBookData);
|
||||
|
||||
if (isInList) {
|
||||
// Referencias a los elementos del nuevo modal (usando 'entry-' prefix)
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const deleteBtn = document.getElementById('modal-delete-btn');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
|
||||
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING';
|
||||
document.getElementById('modal-progress').value = currentListEntry.progress || 0;
|
||||
document.getElementById('modal-score').value = currentListEntry.score || '';
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Edit Library Entry';
|
||||
document.getElementById('modal-delete-btn').style.display = 'block';
|
||||
} else {
|
||||
|
||||
document.getElementById('modal-status').value = 'PLANNING';
|
||||
document.getElementById('modal-progress').value = 0;
|
||||
document.getElementById('modal-score').value = '';
|
||||
|
||||
document.getElementById('modal-title').textContent = 'Add to Library';
|
||||
document.getElementById('modal-delete-btn').style.display = 'none';
|
||||
// **VERIFICACIÓN CRÍTICA**
|
||||
if (!modalTitle || !deleteBtn || !progressLabel) {
|
||||
console.error("Error: Uno o más elementos críticos del modal (título, botón eliminar, o etiqueta de progreso) no se encontraron. Verifique los IDs en el HTML.");
|
||||
return;
|
||||
}
|
||||
|
||||
const progressLabel = document.getElementById('modal-progress-label');
|
||||
// --- Población de Datos ---
|
||||
|
||||
if (isInList && currentListEntry) {
|
||||
// Datos comunes
|
||||
document.getElementById('entry-status').value = currentListEntry.status || 'PLANNING';
|
||||
document.getElementById('entry-progress').value = currentListEntry.progress || 0;
|
||||
document.getElementById('entry-score').value = currentListEntry.score || '';
|
||||
|
||||
// Nuevos datos
|
||||
// Usar formato ISO si viene como ISO, o limpiar si es necesario. Tu ejemplo JSON no tenía fechas.
|
||||
document.getElementById('entry-start-date').value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : '';
|
||||
document.getElementById('entry-end-date').value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : '';
|
||||
document.getElementById('entry-repeat-count').value = currentListEntry.repeat_count || 0;
|
||||
document.getElementById('entry-notes').value = currentListEntry.notes || '';
|
||||
document.getElementById('entry-is-private').checked = currentListEntry.is_private === true || currentListEntry.is_private === 1;
|
||||
|
||||
modalTitle.textContent = 'Edit Library Entry';
|
||||
deleteBtn.style.display = 'block';
|
||||
} else {
|
||||
// Valores por defecto
|
||||
document.getElementById('entry-status').value = 'PLANNING';
|
||||
document.getElementById('entry-progress').value = 0;
|
||||
document.getElementById('entry-score').value = '';
|
||||
document.getElementById('entry-start-date').value = '';
|
||||
document.getElementById('entry-end-date').value = '';
|
||||
document.getElementById('entry-repeat-count').value = 0;
|
||||
document.getElementById('entry-notes').value = '';
|
||||
document.getElementById('entry-is-private').checked = false;
|
||||
|
||||
modalTitle.textContent = 'Add to Library';
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Configuración de Etiquetas y Máximo ---
|
||||
|
||||
if (progressLabel) {
|
||||
const format = currentBookData.format?.toUpperCase() || 'MANGA';
|
||||
if (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') {
|
||||
if (entryType === 'MANGA') {
|
||||
progressLabel.textContent = 'Chapters Read';
|
||||
} else {
|
||||
progressLabel.textContent = 'Volumes/Parts Read';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('modal-progress').max = totalUnits;
|
||||
document.getElementById('entry-progress').max = totalUnits;
|
||||
document.getElementById('add-list-modal').classList.add('active');
|
||||
}
|
||||
|
||||
@@ -144,10 +183,23 @@ function closeAddToListModal() {
|
||||
document.getElementById('add-list-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* REFACTORIZADO para guardar TODOS los campos del modal.
|
||||
*/
|
||||
async function saveToList() {
|
||||
const status = document.getElementById('modal-status').value;
|
||||
const progress = parseInt(document.getElementById('modal-progress').value) || 0;
|
||||
const score = parseFloat(document.getElementById('modal-score').value) || null;
|
||||
// Datos comunes
|
||||
const status = document.getElementById('entry-status').value;
|
||||
const progress = parseInt(document.getElementById('entry-progress').value) || 0;
|
||||
const scoreValue = document.getElementById('entry-score').value;
|
||||
const score = scoreValue ? parseFloat(scoreValue) : null;
|
||||
|
||||
// Nuevos datos
|
||||
const start_date = document.getElementById('entry-start-date').value || null;
|
||||
const end_date = document.getElementById('entry-end-date').value || null;
|
||||
const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0;
|
||||
const notes = document.getElementById('entry-notes').value || null;
|
||||
const is_private = document.getElementById('entry-is-private').checked;
|
||||
|
||||
|
||||
if (!currentBookData) {
|
||||
showNotification('Cannot save: Book data not loaded.', 'error');
|
||||
@@ -167,7 +219,13 @@ async function saveToList() {
|
||||
entry_type: entryType,
|
||||
status: status,
|
||||
progress: progress,
|
||||
score: score
|
||||
score: score,
|
||||
// Nuevos campos
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
repeat_count: repeat_count,
|
||||
notes: notes,
|
||||
is_private: is_private
|
||||
})
|
||||
});
|
||||
|
||||
@@ -175,8 +233,10 @@ async function saveToList() {
|
||||
throw new Error('Failed to save entry');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
isInList = true;
|
||||
currentListEntry = { entry_id: idToSave, source: extensionName || 'anilist', entry_type: entryType, status, progress, score };
|
||||
currentListEntry = data.entry; // Usar la respuesta del servidor si está disponible
|
||||
updateAddToListButton();
|
||||
closeAddToListModal();
|
||||
showNotification(isInList ? 'Updated successfully!' : 'Added to your library!', 'success');
|
||||
@@ -186,16 +246,19 @@ async function saveToList() {
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECCIÓN: Usar el endpoint /list/entry/{id} con los parámetros correctos.
|
||||
async function deleteFromList() {
|
||||
if (!confirm('Remove this book from your library?')) return;
|
||||
|
||||
const idToDelete = extensionName ? bookSlug : bookId;
|
||||
const source = extensionName || 'anilist';
|
||||
const entryType = getBookEntryType(currentBookData); // Obtener el tipo de entrada
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list/entry/${idToDelete}`, {
|
||||
// URL CORRECTA para DELETE: /list/entry/{id}?source={source}&entry_type={entryType}
|
||||
const response = await fetch(`${API_BASE}/list/entry/${idToDelete}?source=${source}&entry_type=${entryType}`, {
|
||||
method: 'DELETE',
|
||||
headers: getSimpleAuthHeaders()
|
||||
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -519,6 +582,7 @@ function openReader(bookId, chapterId, provider) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// El ID del modal sigue siendo 'add-list-modal' para mantener la compatibilidad con el código original.
|
||||
const modal = document.getElementById('add-list-modal');
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
|
||||
@@ -224,6 +224,7 @@ function createListItem(item) {
|
||||
|
||||
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
|
||||
const score = item.score ? item.score.toFixed(1) : null;
|
||||
const repeatCount = item.repeat_count || 0;
|
||||
|
||||
const entryType = (item.entry_type || 'ANIME').toUpperCase();
|
||||
let unitLabel = 'units';
|
||||
@@ -243,6 +244,15 @@ function createListItem(item) {
|
||||
'DROPPED': 'Dropped'
|
||||
};
|
||||
|
||||
const extraInfo = [];
|
||||
if (repeatCount > 0) {
|
||||
extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`);
|
||||
}
|
||||
if (item.is_private) {
|
||||
extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
|
||||
}
|
||||
|
||||
|
||||
div.innerHTML = `
|
||||
<a href="${itemLink}" class="item-poster-link">
|
||||
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
|
||||
@@ -256,6 +266,7 @@ function createListItem(item) {
|
||||
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
|
||||
<span class="meta-pill type-pill">${entryType}</span>
|
||||
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
|
||||
${extraInfo.join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,9 +293,21 @@ function createListItem(item) {
|
||||
function openEditModal(item) {
|
||||
currentEditingEntry = item;
|
||||
|
||||
// Campos existentes
|
||||
document.getElementById('edit-status').value = item.status;
|
||||
document.getElementById('edit-progress').value = item.progress || 0;
|
||||
document.getElementById('edit-score').value = item.score || '';
|
||||
// Asegura que el score se muestre si existe.
|
||||
document.getElementById('edit-score').value = item.score !== null && item.score !== undefined ? item.score : '';
|
||||
|
||||
// Nuevos campos
|
||||
// Usamos split('T')[0] para asegurar que solo se muestra la parte de la fecha (YYYY-MM-DD) si viene con formato DATETIME.
|
||||
document.getElementById('edit-start-date').value = item.start_date?.split('T')[0] || '';
|
||||
document.getElementById('edit-end-date').value = item.end_date?.split('T')[0] || '';
|
||||
document.getElementById('edit-repeat-count').value = item.repeat_count || 0;
|
||||
document.getElementById('edit-notes').value = item.notes || '';
|
||||
// Maneja el booleano o el entero (1/0)
|
||||
document.getElementById('edit-is-private').checked = item.is_private === 1 || item.is_private === true;
|
||||
|
||||
|
||||
const entryType = (item.entry_type || 'ANIME').toUpperCase();
|
||||
const progressLabel = document.querySelector('label[for="edit-progress"]');
|
||||
@@ -315,9 +338,20 @@ function closeEditModal() {
|
||||
async function saveEntry() {
|
||||
if (!currentEditingEntry) return;
|
||||
|
||||
// Campos existentes
|
||||
const status = document.getElementById('edit-status').value;
|
||||
const progress = parseInt(document.getElementById('edit-progress').value) || 0;
|
||||
const score = parseFloat(document.getElementById('edit-score').value) || null;
|
||||
// Usar null si el score está vacío
|
||||
const scoreValue = document.getElementById('edit-score').value;
|
||||
const score = scoreValue ? parseFloat(scoreValue) : null;
|
||||
|
||||
// Nuevos campos
|
||||
const start_date = document.getElementById('edit-start-date').value || null;
|
||||
const end_date = document.getElementById('edit-end-date').value || null;
|
||||
const repeat_count = parseInt(document.getElementById('edit-repeat-count').value) || 0;
|
||||
const notesValue = document.getElementById('edit-notes').value;
|
||||
const notes = notesValue ? notesValue : null;
|
||||
const is_private = document.getElementById('edit-is-private').checked;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list/entry`, {
|
||||
@@ -329,7 +363,13 @@ async function saveEntry() {
|
||||
entry_type: currentEditingEntry.entry_type || 'ANIME',
|
||||
status: status,
|
||||
progress: progress,
|
||||
score: score
|
||||
score: score,
|
||||
// Nuevos datos a enviar al backend
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
repeat_count: repeat_count,
|
||||
notes: notes,
|
||||
is_private: is_private
|
||||
})
|
||||
});
|
||||
|
||||
@@ -354,10 +394,13 @@ async function deleteEntry() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/list/entry/${currentEditingEntry.entry_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getSimpleAuthHeaders()
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE}/list/entry/${currentEditingEntry.entry_id}?source=${currentEditingEntry.source}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: getSimpleAuthHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete entry');
|
||||
@@ -372,6 +415,7 @@ async function deleteEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
|
||||
@@ -46,6 +46,12 @@ async function ensureUserDataDB(dbPath) {
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
score INTEGER,
|
||||
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
repeat_count INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
is_private BOOLEAN NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, entry_id),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
@@ -89,8 +95,6 @@ async function ensureAnilistSchema(db) {
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
updatedAt INTEGER,
|
||||
cache_created_at INTEGER DEFAULT 0,
|
||||
cache_ttl_ms INTEGER DEFAULT 0,
|
||||
full_data JSON
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user