- Реализован парсер RFC в app/reader.py с авто-загрузкой с rfc-editor.org и datatracker.ietf.org - Очистка пагинации ([Page X], \x0c) и разбиение спецификаций на структурированные главы (TOC, разделы) - Эндпоинт POST /api/books/fetch-rfc с защитой от дублирования и поддержкой номеров/URL - Визуальное разделение на книжной полке через hr-разделитель и блок спецификаций IETF & RFC - Быстрые пресеты стандартов (HTTP/1.1, HTTP/2, TLS 1.3, OAuth 2.0, WebSocket, QUIC, TCP, DNS) - Интеграция в интерактивную читалку (перевод по клику, авто-захват контекста, SRS карточки) - Unit-тесты парсера и API (31/31 passed) и обновление Service Worker кэша
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
// =======================================================================
|
|
// Notes Application - API Client Module (Native ES Module)
|
|
// =======================================================================
|
|
|
|
// Persistent session: auto-inject Authorization header if token exists in localStorage
|
|
const originalFetch = window.fetch;
|
|
window.fetch = function(url, options = {}) {
|
|
const token = localStorage.getItem('notes_token');
|
|
if (token && typeof url === 'string' && url.startsWith('/api/')) {
|
|
options.headers = options.headers || {};
|
|
if (options.headers instanceof Headers) {
|
|
if (!options.headers.has('Authorization')) {
|
|
options.headers.set('Authorization', `Bearer ${token}`);
|
|
}
|
|
} else if (Array.isArray(options.headers)) {
|
|
if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) {
|
|
options.headers.push(['Authorization', `Bearer ${token}`]);
|
|
}
|
|
} else {
|
|
if (!options.headers['Authorization']) {
|
|
options.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
}
|
|
}
|
|
return originalFetch.call(this, url, options);
|
|
};
|
|
|
|
export const api = {
|
|
// Auth endpoints
|
|
auth: {
|
|
async me() {
|
|
return await fetch('/api/me');
|
|
},
|
|
async check() {
|
|
const resp = await this.me();
|
|
if (resp.ok) {
|
|
return await resp.json();
|
|
}
|
|
return null;
|
|
},
|
|
async login(username, password) {
|
|
return await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
},
|
|
async logout() {
|
|
return await fetch('/api/logout', { method: 'POST' });
|
|
},
|
|
async changePassword(currentPassword, newPassword) {
|
|
return await fetch('/api/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
old_password: currentPassword,
|
|
new_password: newPassword
|
|
})
|
|
});
|
|
}
|
|
},
|
|
|
|
// Cards & Vocabulary
|
|
cards: {
|
|
async getAll(params = {}) {
|
|
const qs = new URLSearchParams(params).toString();
|
|
const url = qs ? `/api/cards?${qs}` : '/api/cards';
|
|
const resp = await fetch(url);
|
|
return resp.ok ? await resp.json() : [];
|
|
},
|
|
async lookup(term) {
|
|
return await api.lookup.search(term);
|
|
},
|
|
async create(payload) {
|
|
return await fetch('/api/cards', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
async update(id, payload) {
|
|
return await fetch(`/api/cards/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
async delete(id) {
|
|
return await fetch(`/api/cards/${id}`, { method: 'DELETE' });
|
|
},
|
|
async batch(payload) {
|
|
return await fetch('/api/cards/batch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
},
|
|
|
|
// Dictionary Lookup & AI Sentence Translation
|
|
lookup: {
|
|
async search(term, context = '') {
|
|
let url = `/api/lookup?term=${encodeURIComponent(term)}`;
|
|
if (context) {
|
|
url += `&context=${encodeURIComponent(context)}`;
|
|
}
|
|
const resp = await fetch(url);
|
|
if (resp.ok) {
|
|
return await resp.json();
|
|
}
|
|
throw new Error(`Lookup failed: ${resp.status}`);
|
|
},
|
|
async detectPhrasalVerb(sentence, word) {
|
|
const resp = await fetch(`/api/detect-phrasal-verb?sentence=${encodeURIComponent(sentence)}&word=${encodeURIComponent(word)}`);
|
|
return resp.ok ? await resp.json() : { detected: false, phrasal_verb: null };
|
|
},
|
|
async translateSentence(sentence, targetWord = '') {
|
|
const resp = await fetch('/api/translate-sentence', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sentence: sentence,
|
|
target_word: targetWord
|
|
})
|
|
});
|
|
if (resp.ok) {
|
|
return await resp.json();
|
|
}
|
|
throw new Error(`Sentence translation failed: ${resp.status}`);
|
|
},
|
|
async getAiStatus() {
|
|
const resp = await fetch('/api/ai/status');
|
|
return resp.ok ? await resp.json() : { online: false, models: [] };
|
|
}
|
|
},
|
|
|
|
// Collections & Tags
|
|
collections: {
|
|
async getAll() {
|
|
const resp = await fetch('/api/collections');
|
|
return resp.ok ? await resp.json() : [];
|
|
}
|
|
},
|
|
tags: {
|
|
async getAll() {
|
|
const resp = await fetch('/api/tags');
|
|
return resp.ok ? await resp.json() : [];
|
|
}
|
|
},
|
|
|
|
// Statistics & Analytics
|
|
stats: {
|
|
async getStats() {
|
|
const resp = await fetch('/api/stats');
|
|
return resp.ok ? await resp.json() : null;
|
|
},
|
|
async getSrsStats() {
|
|
const resp = await fetch('/api/srs/stats');
|
|
return resp.ok ? await resp.json() : null;
|
|
},
|
|
async getHeatmap(days = 180) {
|
|
const resp = await fetch(`/api/srs/activity-heatmap?days=${days}`);
|
|
return resp.ok ? await resp.json() : { days: [] };
|
|
}
|
|
},
|
|
|
|
// SRS Spaced Repetition
|
|
srs: {
|
|
async getQueue(forceAll = false, tagFilter = null) {
|
|
let url = '/api/srs/queue?limit=50';
|
|
if (forceAll) url += '&force_all=true';
|
|
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
|
const resp = await fetch(url);
|
|
return resp.ok ? await resp.json() : [];
|
|
},
|
|
async submitReview(cardId, rating) {
|
|
return await fetch('/api/srs/review', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ card_id: cardId, rating })
|
|
});
|
|
}
|
|
},
|
|
|
|
// Bookshelf & Reader
|
|
books: {
|
|
async getAll() {
|
|
const resp = await fetch('/api/books');
|
|
return resp.ok ? await resp.json() : [];
|
|
},
|
|
async get(id) {
|
|
const resp = await fetch(`/api/books/${id}`);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to load book ${id}: ${resp.status}`);
|
|
},
|
|
async uploadFile(formData) {
|
|
return await fetch('/api/books', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
},
|
|
async createManual(payload) {
|
|
return await fetch('/api/books', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
async delete(id) {
|
|
return await fetch(`/api/books/${id}`, { method: 'DELETE' });
|
|
},
|
|
async saveProgress(bookId, payload) {
|
|
return await fetch(`/api/books/${bookId}/progress`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
},
|
|
async fetchRfc(query, force = false) {
|
|
const resp = await fetch('/api/books/fetch-rfc', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rfc: query, force: force })
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
let detail = 'Не удалось загрузить RFC';
|
|
try {
|
|
const errData = await resp.json();
|
|
detail = errData.detail || detail;
|
|
} catch (_) {}
|
|
throw new Error(detail);
|
|
}
|
|
},
|
|
|
|
// CEFR Level Tests & Gap Ingestion (Task 15)
|
|
cefr: {
|
|
async getLevels() {
|
|
const resp = await fetch('/api/cefr/levels');
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to load CEFR levels: ${resp.status}`);
|
|
},
|
|
async getQuiz(level = 'A1', count = 10, mode = 'all') {
|
|
const resp = await fetch(`/api/cefr/quiz?level=${encodeURIComponent(level)}&count=${count}&mode=${encodeURIComponent(mode)}`);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to load CEFR quiz: ${resp.status}`);
|
|
},
|
|
async submitAnswer(questionId, selectedOption) {
|
|
const resp = await fetch('/api/cefr/answer', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
question_id: questionId,
|
|
selected_option: selectedOption
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to submit answer: ${resp.status}`);
|
|
},
|
|
async resetLevel(level) {
|
|
const resp = await fetch('/api/cefr/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ level })
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to reset level: ${resp.status}`);
|
|
},
|
|
async addWord(level, term, contextSentence = '') {
|
|
const resp = await fetch('/api/cefr/add-word', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
level: level,
|
|
term: term,
|
|
context_sentence: contextSentence
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to add word: ${resp.status}`);
|
|
}
|
|
},
|
|
|
|
// Murphy Grammar Engine & Handwritten Journaling (Task 16)
|
|
grammar: {
|
|
async getUnits() {
|
|
const resp = await fetch('/api/grammar/units');
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to load grammar units: ${resp.status}`);
|
|
},
|
|
async getUnit(id) {
|
|
const resp = await fetch(`/api/grammar/units/${id}`);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to load grammar unit ${id}: ${resp.status}`);
|
|
},
|
|
async submitAnswer(unitId, exerciseIdx, selectedOption) {
|
|
const resp = await fetch('/api/grammar/answer', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
unit_id: unitId,
|
|
exercise_idx: exerciseIdx,
|
|
selected_option: selectedOption
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to submit grammar answer: ${resp.status}`);
|
|
},
|
|
async completeUnit(unitId, score, handwrittenLogged = false) {
|
|
const resp = await fetch('/api/grammar/complete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
unit_id: unitId,
|
|
score: score,
|
|
handwritten_logged: handwrittenLogged
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to complete unit: ${resp.status}`);
|
|
},
|
|
async toggleHandwritten(unitId, handwritten) {
|
|
const resp = await fetch('/api/grammar/handwritten', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
unit_id: unitId,
|
|
handwritten: handwritten
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to update handwritten status: ${resp.status}`);
|
|
}
|
|
},
|
|
|
|
// Background Tasks & Sentence Mining
|
|
tasks: {
|
|
async mineBooks(bookId = null, level = 'A1', count = 15) {
|
|
const resp = await fetch('/api/tasks/mine-books', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
book_id: bookId,
|
|
level: level,
|
|
count: count
|
|
})
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to start mining task: ${resp.status}`);
|
|
},
|
|
async getActive(taskType = null) {
|
|
let url = '/api/tasks/active';
|
|
if (taskType) url += `?task_type=${encodeURIComponent(taskType)}`;
|
|
const resp = await fetch(url);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to get active task: ${resp.status}`);
|
|
},
|
|
async getStatus(taskId) {
|
|
const resp = await fetch(`/api/tasks/${taskId}`);
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to get task status: ${resp.status}`);
|
|
},
|
|
async cancel(taskId) {
|
|
const resp = await fetch(`/api/tasks/${taskId}/cancel`, {
|
|
method: 'POST'
|
|
});
|
|
if (resp.ok) return await resp.json();
|
|
throw new Error(`Failed to cancel task: ${resp.status}`);
|
|
}
|
|
}
|
|
};
|