1376 lines
64 KiB
Python
1376 lines
64 KiB
Python
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
import socket
|
|||
|
|
import threading
|
|||
|
|
import webbrowser
|
|||
|
|
import contextlib
|
|||
|
|
import traceback
|
|||
|
|
import uvicorn
|
|||
|
|
from typing import List
|
|||
|
|
|
|||
|
|
from fastapi import FastAPI, UploadFile, File, HTTPException
|
|||
|
|
from fastapi.responses import HTMLResponse
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from bs4 import BeautifulSoup
|
|||
|
|
from deep_translator import GoogleTranslator
|
|||
|
|
import httpx
|
|||
|
|
import bleach
|
|||
|
|
import markdown as md_lib
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import trafilatura
|
|||
|
|
except Exception: # библиотека опциональна — без неё импорт по URL вернёт ошибку
|
|||
|
|
trafilatura = None
|
|||
|
|
|
|||
|
|
# ==========================================
|
|||
|
|
# КОНФИГУРАЦИЯ И УТИЛИТЫ
|
|||
|
|
# ==========================================
|
|||
|
|
|
|||
|
|
PORT = 8142
|
|||
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
|||
|
|
LANG_DIR = os.path.join(BASE_DIR, "lang")
|
|||
|
|
|
|||
|
|
# Сколько хранить отданный контент (сек). Readeck забирает ссылку почти сразу,
|
|||
|
|
# но даём запас. Записи старше TTL удаляются, чтобы CONTENT_STORE не рос вечно.
|
|||
|
|
CONTENT_TTL = 3600
|
|||
|
|
# Лимит Google Translate на один запрос ~5000 символов. Берём с запасом.
|
|||
|
|
TRANSLATE_CHAR_LIMIT = 4500
|
|||
|
|
|
|||
|
|
# content_id -> {"html": str, "created": float}
|
|||
|
|
CONTENT_STORE = {}
|
|||
|
|
_store_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
# Разрешённые при санитизации теги/атрибуты (статейная разметка).
|
|||
|
|
ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
|
|||
|
|
"p", "div", "span", "br", "hr", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
|
|||
|
|
"img", "figure", "figcaption", "table", "thead", "tbody", "tfoot", "tr",
|
|||
|
|
"th", "td", "article", "section", "blockquote", "sub", "sup", "u", "s",
|
|||
|
|
]
|
|||
|
|
ALLOWED_ATTRS = {
|
|||
|
|
"*": ["class", "id", "title", "lang"],
|
|||
|
|
"a": ["href", "title", "rel", "target"],
|
|||
|
|
"img": ["src", "alt", "title", "width", "height"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_lan_ip() -> str:
|
|||
|
|
try:
|
|||
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|||
|
|
s.connect(("8.8.8.8", 80))
|
|||
|
|
return s.getsockname()[0]
|
|||
|
|
except Exception:
|
|||
|
|
return "127.0.0.1"
|
|||
|
|
|
|||
|
|
def store_content(html: str) -> str:
|
|||
|
|
"""Сохраняет HTML под новым UUID, попутно подчищая протухшие записи."""
|
|||
|
|
content_id = str(uuid.uuid4())
|
|||
|
|
now = time.time()
|
|||
|
|
with _store_lock:
|
|||
|
|
expired = [k for k, v in CONTENT_STORE.items() if now - v["created"] > CONTENT_TTL]
|
|||
|
|
for k in expired:
|
|||
|
|
CONTENT_STORE.pop(k, None)
|
|||
|
|
CONTENT_STORE[content_id] = {"html": html, "created": now}
|
|||
|
|
return content_id
|
|||
|
|
|
|||
|
|
def sanitize_html(html: str) -> str:
|
|||
|
|
"""Чистит HTML от потенциально опасных тегов/атрибутов перед публикацией."""
|
|||
|
|
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True)
|
|||
|
|
|
|||
|
|
def markdown_to_html(text: str) -> str:
|
|||
|
|
"""Конвертирует Markdown в HTML (с таблицами и блоками кода)."""
|
|||
|
|
return md_lib.markdown(text, extensions=["extra", "sane_lists", "nl2br"])
|
|||
|
|
|
|||
|
|
def chunk_text(text: str, limit: int = TRANSLATE_CHAR_LIMIT) -> List[str]:
|
|||
|
|
"""Режет длинный текст на куски <= limit символов, по возможности по границам
|
|||
|
|
предложений/слов, чтобы не упереться в лимит Google на один запрос."""
|
|||
|
|
if len(text) <= limit:
|
|||
|
|
return [text]
|
|||
|
|
parts = re.split(r"(?<=[.!?。…\n])\s+", text)
|
|||
|
|
chunks, buf = [], ""
|
|||
|
|
for part in parts:
|
|||
|
|
# Одно «предложение» само длиннее лимита — режем жёстко по символам.
|
|||
|
|
while len(part) > limit:
|
|||
|
|
if buf:
|
|||
|
|
chunks.append(buf)
|
|||
|
|
buf = ""
|
|||
|
|
chunks.append(part[:limit])
|
|||
|
|
part = part[limit:]
|
|||
|
|
if len(buf) + len(part) + 1 <= limit:
|
|||
|
|
buf = f"{buf} {part}".strip()
|
|||
|
|
else:
|
|||
|
|
if buf:
|
|||
|
|
chunks.append(buf)
|
|||
|
|
buf = part
|
|||
|
|
if buf:
|
|||
|
|
chunks.append(buf)
|
|||
|
|
return chunks
|
|||
|
|
|
|||
|
|
def translate_long(text: str, target_lang: str) -> str:
|
|||
|
|
"""Переводит произвольно длинный текст, разбивая его на куски под лимит Google."""
|
|||
|
|
if not text or not text.strip():
|
|||
|
|
return text
|
|||
|
|
translator = GoogleTranslator(source="auto", target=target_lang)
|
|||
|
|
out = []
|
|||
|
|
for chunk in chunk_text(text):
|
|||
|
|
try:
|
|||
|
|
res = translator.translate(chunk)
|
|||
|
|
out.append(res if res else chunk)
|
|||
|
|
except Exception:
|
|||
|
|
out.append(chunk)
|
|||
|
|
return " ".join(out)
|
|||
|
|
|
|||
|
|
def extract_metadata_from_html(html: str) -> dict:
|
|||
|
|
"""Достаёт title/author/description/site_name/date из HTML-метатегов."""
|
|||
|
|
soup = BeautifulSoup(html, "html.parser")
|
|||
|
|
|
|||
|
|
def meta(*, name=None, prop=None):
|
|||
|
|
if name:
|
|||
|
|
tag = soup.find("meta", attrs={"name": name})
|
|||
|
|
else:
|
|||
|
|
tag = soup.find("meta", attrs={"property": prop})
|
|||
|
|
return tag.get("content", "").strip() if tag and tag.get("content") else ""
|
|||
|
|
|
|||
|
|
title = ""
|
|||
|
|
if soup.title and soup.title.string:
|
|||
|
|
title = soup.title.string.strip()
|
|||
|
|
title = meta(prop="og:title") or title
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"title": title,
|
|||
|
|
"authors": meta(name="author") or meta(prop="article:author"),
|
|||
|
|
"description": meta(name="description") or meta(prop="og:description"),
|
|||
|
|
"site_name": meta(prop="og:site_name"),
|
|||
|
|
"date": meta(prop="article:published_time") or meta(name="date"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_available_languages() -> list:
|
|||
|
|
"""Сканирует папку lang и возвращает список доступных языков."""
|
|||
|
|
if not os.path.exists(LANG_DIR):
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
languages = []
|
|||
|
|
try:
|
|||
|
|
for filename in os.listdir(LANG_DIR):
|
|||
|
|
if filename.endswith('.json'):
|
|||
|
|
lang_code = filename[:-5] # убираем .json
|
|||
|
|
filepath = os.path.join(LANG_DIR, filename)
|
|||
|
|
try:
|
|||
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
languages.append({
|
|||
|
|
'code': lang_code,
|
|||
|
|
'name': data.get('lang_name', lang_code),
|
|||
|
|
'native_name': data.get('lang_name', lang_code)
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[WARNING] Не удалось загрузить {filename}: {e}")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[WARNING] Ошибка при сканировании папки lang: {e}")
|
|||
|
|
|
|||
|
|
return languages
|
|||
|
|
|
|||
|
|
def load_language(lang_code: str) -> dict:
|
|||
|
|
"""Загружает файл локализации для указанного языка."""
|
|||
|
|
filepath = os.path.join(LANG_DIR, f"{lang_code}.json")
|
|||
|
|
if not os.path.exists(filepath):
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[WARNING] Не удалось загрузить локализацию {lang_code}: {e}")
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
def load_config() -> dict:
|
|||
|
|
default_config = {
|
|||
|
|
"readeck_url": "",
|
|||
|
|
"readeck_token": "",
|
|||
|
|
"public_host": get_lan_ip(),
|
|||
|
|
"language": "ru"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if os.path.exists(CONFIG_FILE):
|
|||
|
|
try:
|
|||
|
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
if isinstance(data, dict):
|
|||
|
|
default_config.update(data)
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
print(f"\n[WARNING] ОШИБКА В config.json! Файл содержит неверный формат JSON: {e}")
|
|||
|
|
print("[WARNING] Проверьте, нет ли там лишних запятых или пропущенных кавычек.\n")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"\n[WARNING] Не удалось прочитать config.json: {e}\n")
|
|||
|
|
else:
|
|||
|
|
print(f"\n[INFO] Файл {CONFIG_FILE} не найден. Используются пустые настройки.\n")
|
|||
|
|
|
|||
|
|
return default_config
|
|||
|
|
|
|||
|
|
def save_config(config: dict):
|
|||
|
|
try:
|
|||
|
|
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(config, f, indent=4)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"\n[ERROR] Не удалось сохранить config.json: {e}\n")
|
|||
|
|
raise HTTPException(status_code=500, detail=f"Ошибка записи в файл настроек: {e}")
|
|||
|
|
|
|||
|
|
# ==========================================
|
|||
|
|
# ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
|
|||
|
|
# ==========================================
|
|||
|
|
|
|||
|
|
@contextlib.asynccontextmanager
|
|||
|
|
async def lifespan(app: FastAPI):
|
|||
|
|
def open_browser():
|
|||
|
|
webbrowser.open(f"http://127.0.0.1:{PORT}")
|
|||
|
|
threading.Timer(1.5, open_browser).start()
|
|||
|
|
yield
|
|||
|
|
|
|||
|
|
app = FastAPI(title="Readeck Local Importer", lifespan=lifespan)
|
|||
|
|
|
|||
|
|
# ==========================================
|
|||
|
|
# МОДЕЛИ ДАННЫХ
|
|||
|
|
# ==========================================
|
|||
|
|
|
|||
|
|
class SettingsModel(BaseModel):
|
|||
|
|
readeck_url: str = ""
|
|||
|
|
readeck_token: str = ""
|
|||
|
|
public_host: str = ""
|
|||
|
|
language: str = "ru"
|
|||
|
|
|
|||
|
|
class TranslateRequest(BaseModel):
|
|||
|
|
content: str
|
|||
|
|
target_lang: str = "ru"
|
|||
|
|
|
|||
|
|
class FetchUrlRequest(BaseModel):
|
|||
|
|
url: str
|
|||
|
|
|
|||
|
|
class ExtractMetaRequest(BaseModel):
|
|||
|
|
content: str
|
|||
|
|
|
|||
|
|
class MarkdownRequest(BaseModel):
|
|||
|
|
content: str
|
|||
|
|
|
|||
|
|
class SubmitRequest(BaseModel):
|
|||
|
|
content: str
|
|||
|
|
title: str = ""
|
|||
|
|
description: str = ""
|
|||
|
|
authors: str = ""
|
|||
|
|
site_name: str = ""
|
|||
|
|
date: str = ""
|
|||
|
|
language: str = "ru"
|
|||
|
|
tags: List[str] = []
|
|||
|
|
favorite: bool = False
|
|||
|
|
archive: bool = False
|
|||
|
|
content_format: str = "html" # html | markdown | text
|
|||
|
|
|
|||
|
|
# ==========================================
|
|||
|
|
# ФРОНТЕНД (HTML / JS)
|
|||
|
|
# ==========================================
|
|||
|
|
|
|||
|
|
HTML_TEMPLATE = """
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="ru">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>Readeck Local Importer</title>
|
|||
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|||
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|||
|
|
<style>
|
|||
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|||
|
|
|
|||
|
|
* { font-family: 'Inter', sans-serif; }
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
|||
|
|
background-attachment: fixed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.glass {
|
|||
|
|
background: rgba(255, 255, 255, 0.85);
|
|||
|
|
backdrop-filter: blur(20px);
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.glass-dark {
|
|||
|
|
background: rgba(255, 255, 255, 0.95);
|
|||
|
|
backdrop-filter: blur(30px);
|
|||
|
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.gradient-text {
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
-webkit-background-clip: text;
|
|||
|
|
-webkit-text-fill-color: transparent;
|
|||
|
|
background-clip: text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-gradient {
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-gradient:hover:not(:disabled) {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 10px 25px -5px rgba(102, 126, 234, 0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-success {
|
|||
|
|
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-success:hover:not(:disabled) {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 10px 25px -5px rgba(17, 153, 142, 0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-modern {
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
border: 2px solid rgba(102, 126, 234, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-modern:focus {
|
|||
|
|
border-color: #667eea;
|
|||
|
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-hover {
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-hover:hover {
|
|||
|
|
transform: translateY(-4px);
|
|||
|
|
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes fadeIn {
|
|||
|
|
from { opacity: 0; transform: translateY(10px); }
|
|||
|
|
to { opacity: 1; transform: translateY(0); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fade-in {
|
|||
|
|
animation: fadeIn 0.5s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.badge {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
padding: 0.5rem 1rem;
|
|||
|
|
border-radius: 9999px;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------- Тёмная тема ---------- */
|
|||
|
|
html.dark body {
|
|||
|
|
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #312e3a 100%);
|
|||
|
|
}
|
|||
|
|
html.dark .glass {
|
|||
|
|
background: rgba(30, 41, 59, 0.7);
|
|||
|
|
border: 1px solid rgba(148, 163, 184, 0.15);
|
|||
|
|
}
|
|||
|
|
html.dark .glass-dark {
|
|||
|
|
background: rgba(15, 23, 42, 0.85);
|
|||
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|||
|
|
}
|
|||
|
|
html.dark .input-modern {
|
|||
|
|
background: rgba(15, 23, 42, 0.6);
|
|||
|
|
border-color: rgba(129, 140, 248, 0.3);
|
|||
|
|
color: #e2e8f0;
|
|||
|
|
}
|
|||
|
|
html.dark .input-modern::placeholder { color: #64748b; }
|
|||
|
|
html.dark .text-gray-700,
|
|||
|
|
html.dark .text-gray-800,
|
|||
|
|
html.dark .text-gray-900 { color: #cbd5e1 !important; }
|
|||
|
|
html.dark .gradient-text {
|
|||
|
|
background: linear-gradient(135deg, #818cf8 0%, #c084fc 100%);
|
|||
|
|
-webkit-background-clip: text;
|
|||
|
|
background-clip: text;
|
|||
|
|
}
|
|||
|
|
html.dark .bg-green-50 { background: rgba(16, 185, 129, 0.15) !important; }
|
|||
|
|
html.dark .bg-red-50 { background: rgba(239, 68, 68, 0.15) !important; }
|
|||
|
|
|
|||
|
|
/* ---------- Предпросмотр ---------- */
|
|||
|
|
.preview-body {
|
|||
|
|
line-height: 1.7;
|
|||
|
|
color: #1f2937;
|
|||
|
|
}
|
|||
|
|
.preview-body h1, .preview-body h2, .preview-body h3 { font-weight: 700; margin: 1rem 0 0.5rem; }
|
|||
|
|
.preview-body p { margin: 0.75rem 0; }
|
|||
|
|
.preview-body img { max-width: 100%; border-radius: 0.5rem; }
|
|||
|
|
.preview-body a { color: #6366f1; text-decoration: underline; }
|
|||
|
|
.preview-body pre { background: #f1f5f9; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
|
|||
|
|
html.dark .preview-body { color: #e2e8f0; }
|
|||
|
|
html.dark .preview-body pre { background: #1e293b; }
|
|||
|
|
|
|||
|
|
.drag-active {
|
|||
|
|
border-color: #667eea !important;
|
|||
|
|
background: rgba(102, 126, 234, 0.08) !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.spinner {
|
|||
|
|
border: 2px solid rgba(255,255,255,0.3);
|
|||
|
|
border-top-color: #fff;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
width: 14px; height: 14px;
|
|||
|
|
display: inline-block;
|
|||
|
|
animation: spin 0.7s linear infinite;
|
|||
|
|
}
|
|||
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body class="min-h-screen">
|
|||
|
|
<div id="app" class="max-w-7xl mx-auto p-6 md:p-8">
|
|||
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 fade-in">
|
|||
|
|
<div>
|
|||
|
|
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-2 drop-shadow-lg">
|
|||
|
|
📚 {{ t('app_title') }}
|
|||
|
|
</h1>
|
|||
|
|
<p class="text-white/80 text-sm md:text-base">{{ t('app_subtitle') }}</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex gap-3">
|
|||
|
|
<button @click="toggleDark" class="glass px-5 py-3 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 font-semibold text-gray-700 hover:scale-105" :title="isDark ? t('theme_light') : t('theme_dark')">
|
|||
|
|
{{ isDark ? '☀️' : '🌙' }}
|
|||
|
|
</button>
|
|||
|
|
<button @click="showSettings = true" class="glass px-6 py-3 rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 font-semibold text-gray-700 hover:scale-105">
|
|||
|
|
⚙️ {{ t('settings') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8">
|
|||
|
|
<!-- Левая панель: Загрузка и контент -->
|
|||
|
|
<div class="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
|
|||
|
|
<div class="flex items-center gap-3 mb-6">
|
|||
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
|||
|
|
1
|
|||
|
|
</div>
|
|||
|
|
<h2 class="text-2xl font-bold gradient-text">{{ t('section_upload') }}</h2>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Импорт по URL -->
|
|||
|
|
<div class="mb-5">
|
|||
|
|
<label class="block text-sm font-semibold mb-2 text-gray-700">🔗 {{ t('import_url') }}</label>
|
|||
|
|
<div class="flex flex-col sm:flex-row gap-2">
|
|||
|
|
<input v-model="urlInput" type="text" @keyup.enter="fetchUrl" class="flex-1 input-modern rounded-xl p-3 text-sm" :placeholder="t('import_url_placeholder')">
|
|||
|
|
<button @click="fetchUrl" :disabled="isFetching || !urlInput" class="btn-gradient text-white px-5 py-3 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed shadow-lg flex-shrink-0 flex items-center justify-center gap-2">
|
|||
|
|
<span v-if="isFetching" class="spinner"></span>
|
|||
|
|
{{ isFetching ? t('import_url_loading') : t('import_url_button') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-5">
|
|||
|
|
<label class="block text-sm font-semibold mb-2 text-gray-700">📄 {{ t('file_upload') }}</label>
|
|||
|
|
<input type="file" accept=".txt,.html,.md,.markdown" @change="uploadFile" class="block w-full text-sm file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-gradient-to-r file:from-blue-500 file:to-purple-600 file:text-white hover:file:from-blue-600 hover:file:to-purple-700 file:cursor-pointer file:transition-all file:shadow-lg cursor-pointer"/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Формат контента -->
|
|||
|
|
<div class="mb-5">
|
|||
|
|
<label class="block text-sm font-semibold mb-2 text-gray-700">📐 {{ t('content_format') }}</label>
|
|||
|
|
<div class="flex gap-2 flex-wrap">
|
|||
|
|
<button @click="form.content_format = 'html'"
|
|||
|
|
:class="form.content_format === 'html' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
|
|||
|
|
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
|
|||
|
|
{{ t('format_html') }}
|
|||
|
|
</button>
|
|||
|
|
<button @click="form.content_format = 'markdown'"
|
|||
|
|
:class="form.content_format === 'markdown' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
|
|||
|
|
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
|
|||
|
|
{{ t('format_markdown') }}
|
|||
|
|
</button>
|
|||
|
|
<button @click="form.content_format = 'text'"
|
|||
|
|
:class="form.content_format === 'text' ? 'btn-gradient text-white shadow-lg' : 'glass text-gray-700'"
|
|||
|
|
class="px-4 py-2 rounded-xl text-sm font-semibold transition-all">
|
|||
|
|
{{ t('format_text') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<div class="flex justify-between items-center mb-2">
|
|||
|
|
<label class="block text-sm font-semibold text-gray-700">✍️ {{ t('content_label') }}</label>
|
|||
|
|
<span class="text-xs text-gray-500 font-medium">{{ charCount }} {{ t('content_stats', { words: wordCount }) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<textarea v-model="form.content" rows="14"
|
|||
|
|
@dragover.prevent="dragActive = true" @dragleave.prevent="dragActive = false" @drop.prevent="onDrop"
|
|||
|
|
:class="{ 'drag-active': dragActive }"
|
|||
|
|
class="w-full input-modern rounded-2xl p-4 font-mono text-sm resize-none transition-all"
|
|||
|
|
:placeholder="t('content_placeholder')"></textarea>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 p-5 glass rounded-2xl shadow-lg mb-3">
|
|||
|
|
<span class="text-sm font-semibold text-gray-700">🌐 {{ t('translation') }}</span>
|
|||
|
|
<select v-model="targetLang" class="input-modern rounded-xl p-2.5 text-sm font-medium flex-1">
|
|||
|
|
<option v-for="lang in languages" :key="lang.code" :value="lang.code">{{ lang.label }}</option>
|
|||
|
|
</select>
|
|||
|
|
<button @click="translateContent" :disabled="isTranslating || !form.content" class="btn-gradient text-white px-6 py-2.5 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed shadow-lg flex-shrink-0 flex items-center justify-center gap-2">
|
|||
|
|
<span v-if="isTranslating" class="spinner"></span>
|
|||
|
|
{{ isTranslating ? t('translating') : t('translate_button') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button @click="openPreview" :disabled="!form.content || isPreviewing" class="w-full glass text-gray-700 font-semibold py-3 px-4 rounded-2xl shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
|||
|
|
<span v-if="isPreviewing" class="spinner" style="border-top-color:#667eea;"></span>
|
|||
|
|
{{ t('preview_button') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Правая панель: Метаданные и отправка -->
|
|||
|
|
<div class="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
|
|||
|
|
<div class="flex items-center justify-between gap-3 mb-6">
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
|||
|
|
2
|
|||
|
|
</div>
|
|||
|
|
<h2 class="text-2xl font-bold gradient-text">{{ t('section_metadata') }}</h2>
|
|||
|
|
</div>
|
|||
|
|
<button @click="autofillMeta" :disabled="!form.content || isExtracting" class="glass text-gray-700 px-4 py-2 rounded-xl text-xs font-semibold shadow hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" :title="t('autofill_tooltip')">
|
|||
|
|
<span v-if="isExtracting" class="spinner" style="border-top-color:#667eea;"></span>
|
|||
|
|
{{ t('autofill_button') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
|||
|
|
<div class="sm:col-span-2">
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_title') }}</label>
|
|||
|
|
<input v-model="form.title" type="text" class="w-full input-modern rounded-xl p-3 text-sm font-medium" :placeholder="t('meta_title_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_authors') }}</label>
|
|||
|
|
<input v-model="form.authors" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_authors_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_date') }}</label>
|
|||
|
|
<input v-model="form.date" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_date_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div class="sm:col-span-2">
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_description') }}</label>
|
|||
|
|
<input v-model="form.description" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_description_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div class="sm:col-span-2">
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('meta_site_name') }}</label>
|
|||
|
|
<input v-model="form.site_name" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('meta_site_name_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex items-center gap-3 mb-6">
|
|||
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
|||
|
|
3
|
|||
|
|
</div>
|
|||
|
|
<h2 class="text-2xl font-bold gradient-text">{{ t('section_readeck') }}</h2>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-5">
|
|||
|
|
<label class="block text-xs font-bold mb-2 text-gray-700 uppercase tracking-wide">{{ t('tags_label') }}</label>
|
|||
|
|
<input v-model="tagsInput" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('tags_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex gap-4 mb-6 flex-wrap">
|
|||
|
|
<label class="flex items-center gap-2 text-sm font-semibold cursor-pointer glass px-4 py-2.5 rounded-xl hover:shadow-lg transition-all">
|
|||
|
|
<input type="checkbox" v-model="form.favorite" class="w-5 h-5 rounded-lg text-purple-600 focus:ring-purple-500 focus:ring-2 cursor-pointer">
|
|||
|
|
{{ t('favorite') }}
|
|||
|
|
</label>
|
|||
|
|
<label class="flex items-center gap-2 text-sm font-semibold cursor-pointer glass px-4 py-2.5 rounded-xl hover:shadow-lg transition-all">
|
|||
|
|
<input type="checkbox" v-model="form.archive" class="w-5 h-5 rounded-lg text-purple-600 focus:ring-purple-500 focus:ring-2 cursor-pointer">
|
|||
|
|
{{ t('archive') }}
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button @click="submitBookmark" :disabled="isSubmitting" class="w-full btn-success text-white font-bold py-4 px-6 rounded-2xl shadow-2xl disabled:opacity-50 disabled:cursor-not-allowed text-lg">
|
|||
|
|
{{ isSubmitting ? t('submitting') : t('submit_button') }}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div v-if="resultMessage" :class="resultIsError ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200'" class="mt-5 p-4 rounded-2xl border-2 text-sm font-semibold whitespace-pre-wrap shadow-lg fade-in">
|
|||
|
|
{{ resultMessage }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Модальное окно настроек -->
|
|||
|
|
<div v-if="showSettings" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 fade-in">
|
|||
|
|
<div class="glass-dark p-8 rounded-3xl w-full max-w-md shadow-2xl">
|
|||
|
|
<div class="flex items-center gap-3 mb-6">
|
|||
|
|
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-2xl shadow-lg">
|
|||
|
|
⚙️
|
|||
|
|
</div>
|
|||
|
|
<h2 class="text-2xl font-bold gradient-text">{{ t('settings_title') }}</h2>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-4">
|
|||
|
|
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_readeck_url') }}</label>
|
|||
|
|
<input v-model="settings.readeck_url" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('settings_readeck_url_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-4">
|
|||
|
|
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_token') }}</label>
|
|||
|
|
<input v-model="settings.readeck_token" type="password" class="w-full input-modern rounded-xl p-3 text-sm font-mono" :placeholder="t('settings_token_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-4">
|
|||
|
|
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_lan_ip') }}</label>
|
|||
|
|
<input v-model="settings.public_host" type="text" class="w-full input-modern rounded-xl p-3 text-sm" :placeholder="t('settings_lan_ip_placeholder')">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-6">
|
|||
|
|
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_language') }}</label>
|
|||
|
|
<select v-model="settings.language" @change="changeLanguage(settings.language)" class="w-full input-modern rounded-xl p-3 text-sm">
|
|||
|
|
<option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">{{ lang.name }}</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button @click="testConnection" :disabled="isTesting" class="w-full glass text-gray-700 px-6 py-3 rounded-xl font-semibold shadow hover:shadow-lg transition-all mb-3 disabled:opacity-50 flex items-center justify-center gap-2">
|
|||
|
|
<span v-if="isTesting" class="spinner" style="border-top-color:#667eea;"></span>
|
|||
|
|
{{ isTesting ? t('settings_testing') : t('settings_test') }}
|
|||
|
|
</button>
|
|||
|
|
<div v-if="testMessage" :class="testIsError ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200'" class="mb-4 p-3 rounded-xl border-2 text-sm font-semibold fade-in">
|
|||
|
|
{{ testMessage }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex gap-3">
|
|||
|
|
<button @click="showSettings = false" class="flex-1 px-6 py-3 glass rounded-xl font-semibold hover:shadow-lg transition-all">
|
|||
|
|
{{ t('settings_cancel') }}
|
|||
|
|
</button>
|
|||
|
|
<button @click="saveSettings" class="flex-1 btn-gradient text-white px-6 py-3 rounded-xl font-semibold shadow-lg">
|
|||
|
|
{{ t('settings_save') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Модальное окно предпросмотра -->
|
|||
|
|
<div v-if="showPreview" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 fade-in" @click.self="showPreview = false">
|
|||
|
|
<div class="glass-dark rounded-3xl w-full max-w-3xl max-h-[90vh] shadow-2xl flex flex-col overflow-hidden">
|
|||
|
|
<div class="flex items-center justify-between gap-3 p-6 border-b border-gray-200/30">
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xl shadow-lg">👁️</div>
|
|||
|
|
<h2 class="text-2xl font-bold gradient-text">{{ t('preview_title') }}</h2>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<a v-if="previewUrl" :href="previewUrl" target="_blank" class="glass text-gray-700 px-4 py-2 rounded-xl text-sm font-semibold shadow hover:shadow-lg transition-all">{{ t('preview_new_tab') }}</a>
|
|||
|
|
<button @click="showPreview = false" class="glass text-gray-700 px-4 py-2 rounded-xl text-sm font-semibold shadow hover:shadow-lg transition-all">{{ t('preview_close') }}</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="p-6 overflow-y-auto">
|
|||
|
|
<iframe v-if="previewUrl" :src="previewUrl" class="w-full rounded-2xl bg-white border-0" style="height:65vh;"></iframe>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const { createApp, ref, computed, onMounted, reactive, watch } = Vue;
|
|||
|
|
createApp({
|
|||
|
|
setup() {
|
|||
|
|
const showSettings = ref(false);
|
|||
|
|
const showPreview = ref(false);
|
|||
|
|
const isTranslating = ref(false);
|
|||
|
|
const isSubmitting = ref(false);
|
|||
|
|
const isFetching = ref(false);
|
|||
|
|
const isExtracting = ref(false);
|
|||
|
|
const isPreviewing = ref(false);
|
|||
|
|
const isTesting = ref(false);
|
|||
|
|
const resultMessage = ref("");
|
|||
|
|
const resultIsError = ref(false);
|
|||
|
|
const testMessage = ref("");
|
|||
|
|
const testIsError = ref(false);
|
|||
|
|
const targetLang = ref("ru");
|
|||
|
|
const tagsInput = ref("");
|
|||
|
|
const urlInput = ref("");
|
|||
|
|
const previewUrl = ref("");
|
|||
|
|
const dragActive = ref(false);
|
|||
|
|
const isDark = ref(false);
|
|||
|
|
|
|||
|
|
// Языки перевода (поддерживаются deep_translator / Google).
|
|||
|
|
const languages = [
|
|||
|
|
{ code: "ru", label: "🇷🇺 Русский" },
|
|||
|
|
{ code: "en", label: "🇬🇧 Английский" },
|
|||
|
|
{ code: "es", label: "🇪🇸 Испанский" },
|
|||
|
|
{ code: "de", label: "🇩🇪 Немецкий" },
|
|||
|
|
{ code: "fr", label: "🇫🇷 Французский" },
|
|||
|
|
{ code: "it", label: "🇮🇹 Итальянский" },
|
|||
|
|
{ code: "pt", label: "🇵🇹 Португальский" },
|
|||
|
|
{ code: "pl", label: "🇵🇱 Польский" },
|
|||
|
|
{ code: "uk", label: "🇺🇦 Украинский" },
|
|||
|
|
{ code: "nl", label: "🇳🇱 Нидерландский" },
|
|||
|
|
{ code: "tr", label: "🇹🇷 Турецкий" },
|
|||
|
|
{ code: "zh-CN", label: "🇨🇳 Китайский (упр.)" },
|
|||
|
|
{ code: "ja", label: "🇯🇵 Японский" },
|
|||
|
|
{ code: "ko", label: "🇰🇷 Корейский" },
|
|||
|
|
{ code: "ar", label: "🇸🇦 Арабский" },
|
|||
|
|
{ code: "hi", label: "🇮🇳 Хинди" },
|
|||
|
|
{ code: "cs", label: "🇨🇿 Чешский" },
|
|||
|
|
{ code: "sv", label: "🇸🇪 Шведский" },
|
|||
|
|
{ code: "fi", label: "🇫🇮 Финский" },
|
|||
|
|
{ code: "el", label: "🇬🇷 Греческий" },
|
|||
|
|
{ code: "he", label: "🇮🇱 Иврит" },
|
|||
|
|
{ code: "vi", label: "🇻🇳 Вьетнамский" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const formats = [
|
|||
|
|
{ value: "html", label: "HTML" },
|
|||
|
|
{ value: "markdown", label: "Markdown" },
|
|||
|
|
{ value: "text", label: "Текст" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const settings = reactive({ readeck_url: "", readeck_token: "", public_host: "", language: "ru" });
|
|||
|
|
const form = reactive({
|
|||
|
|
content: "", title: "", description: "", authors: "",
|
|||
|
|
site_name: "Local Importer", date: new Date().toISOString().split('T')[0],
|
|||
|
|
favorite: false, archive: false, content_format: "html"
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Локализация
|
|||
|
|
const availableLanguages = ref([]);
|
|||
|
|
const translations = ref({});
|
|||
|
|
const currentLang = ref("ru");
|
|||
|
|
|
|||
|
|
// Функция перевода
|
|||
|
|
const t = (key, params = {}) => {
|
|||
|
|
let text = translations.value[key] || key;
|
|||
|
|
// Подстановка параметров {param}
|
|||
|
|
Object.keys(params).forEach(k => {
|
|||
|
|
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), params[k]);
|
|||
|
|
});
|
|||
|
|
return text;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Загрузка языка
|
|||
|
|
const loadLanguage = async (langCode) => {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/language/${langCode}`);
|
|||
|
|
if (res.ok) {
|
|||
|
|
translations.value = await res.json();
|
|||
|
|
currentLang.value = langCode;
|
|||
|
|
document.documentElement.lang = langCode;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Ошибка загрузки локализации:", e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Смена языка
|
|||
|
|
const changeLanguage = async (langCode) => {
|
|||
|
|
await loadLanguage(langCode);
|
|||
|
|
settings.language = langCode;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Счётчик символов/слов.
|
|||
|
|
const charCount = computed(() => form.content.length);
|
|||
|
|
const wordCount = computed(() => {
|
|||
|
|
const t = form.content.trim();
|
|||
|
|
return t ? t.split(/\\s+/).length : 0;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const DRAFT_KEY = "readeck_importer_draft";
|
|||
|
|
|
|||
|
|
// --- Тёмная тема ---
|
|||
|
|
const applyDark = (val) => {
|
|||
|
|
isDark.value = val;
|
|||
|
|
document.documentElement.classList.toggle("dark", val);
|
|||
|
|
};
|
|||
|
|
const toggleDark = () => {
|
|||
|
|
applyDark(!isDark.value);
|
|||
|
|
localStorage.setItem("readeck_theme", isDark.value ? "dark" : "light");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
// Загрузка доступных языков
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/languages");
|
|||
|
|
if (res.ok) {
|
|||
|
|
const data = await res.json();
|
|||
|
|
availableLanguages.value = data.languages || [];
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Ошибка загрузки списка языков:", e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Загрузка настроек
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/settings");
|
|||
|
|
if (res.ok) {
|
|||
|
|
const data = await res.json();
|
|||
|
|
Object.assign(settings, data);
|
|||
|
|
// Загружаем язык из настроек
|
|||
|
|
await loadLanguage(settings.language || "ru");
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("Ошибка загрузки настроек при старте:", e);
|
|||
|
|
// Загружаем язык по умолчанию
|
|||
|
|
await loadLanguage("ru");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Тема: сохранённая или системная.
|
|||
|
|
const savedTheme = localStorage.getItem("readeck_theme");
|
|||
|
|
if (savedTheme) applyDark(savedTheme === "dark");
|
|||
|
|
else applyDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|||
|
|
|
|||
|
|
// Восстановление черновика.
|
|||
|
|
try {
|
|||
|
|
const draft = localStorage.getItem(DRAFT_KEY);
|
|||
|
|
if (draft) Object.assign(form, JSON.parse(draft));
|
|||
|
|
} catch (e) { /* ignore */ }
|
|||
|
|
|
|||
|
|
if (!settings.readeck_token || !settings.readeck_url) {
|
|||
|
|
showSettings.value = true;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Автосохранение черновика в localStorage.
|
|||
|
|
watch(form, (val) => {
|
|||
|
|
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(val)); } catch (e) {}
|
|||
|
|
}, { deep: true });
|
|||
|
|
|
|||
|
|
const parseError = async (res) => {
|
|||
|
|
try {
|
|||
|
|
const d = await res.json();
|
|||
|
|
return typeof d.detail === "string" ? d.detail : JSON.stringify(d.detail);
|
|||
|
|
} catch (e) {
|
|||
|
|
return `Ошибка сервера (${res.status})`;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const saveSettings = async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/settings", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(settings)
|
|||
|
|
});
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert(t('error_settings_save', { error: await parseError(res) }));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showSettings.value = false;
|
|||
|
|
testMessage.value = "";
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(t('error_network', { error: e.message }));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const testConnection = async () => {
|
|||
|
|
isTesting.value = true;
|
|||
|
|
testMessage.value = "";
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/test-connection", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(settings)
|
|||
|
|
});
|
|||
|
|
const data = await res.json().catch(() => ({}));
|
|||
|
|
if (!res.ok) throw new Error(data.detail || `Код ${res.status}`);
|
|||
|
|
testIsError.value = false;
|
|||
|
|
testMessage.value = t('test_success');
|
|||
|
|
} catch (e) {
|
|||
|
|
testIsError.value = true;
|
|||
|
|
testMessage.value = t('test_error', { error: e.message });
|
|||
|
|
} finally {
|
|||
|
|
isTesting.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const detectFormat = (name) => {
|
|||
|
|
const n = (name || "").toLowerCase();
|
|||
|
|
if (n.endsWith(".md") || n.endsWith(".markdown")) return "markdown";
|
|||
|
|
if (n.endsWith(".html") || n.endsWith(".htm")) return "html";
|
|||
|
|
return "text";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loadFile = async (file) => {
|
|||
|
|
if (!file) return;
|
|||
|
|
const fd = new FormData();
|
|||
|
|
fd.append("file", file);
|
|||
|
|
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
|||
|
|
if (!res.ok) {
|
|||
|
|
alert(t('error_file_read', { error: await parseError(res) }));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const data = await res.json();
|
|||
|
|
form.content = data.content;
|
|||
|
|
form.content_format = detectFormat(file.name);
|
|||
|
|
if (!form.title) form.title = file.name.replace(/\\.[^.]+$/, "");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const uploadFile = async (e) => {
|
|||
|
|
await loadFile(e.target.files[0]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onDrop = async (e) => {
|
|||
|
|
dragActive.value = false;
|
|||
|
|
const file = e.dataTransfer.files && e.dataTransfer.files[0];
|
|||
|
|
if (file) await loadFile(file);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fetchUrl = async () => {
|
|||
|
|
if (!urlInput.value) return;
|
|||
|
|
isFetching.value = true;
|
|||
|
|
resultMessage.value = "";
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/fetch-url", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ url: urlInput.value })
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.detail || "Не удалось загрузить");
|
|||
|
|
form.content = data.content;
|
|||
|
|
form.content_format = "html";
|
|||
|
|
// Подставляем метаданные, не затирая уже заполненные поля.
|
|||
|
|
const m = data.meta || {};
|
|||
|
|
if (m.title && !form.title) form.title = m.title;
|
|||
|
|
if (m.authors && !form.authors) form.authors = m.authors;
|
|||
|
|
if (m.description && !form.description) form.description = m.description;
|
|||
|
|
if (m.site_name) form.site_name = m.site_name;
|
|||
|
|
if (m.date) form.date = (m.date || "").split("T")[0];
|
|||
|
|
resultIsError.value = false;
|
|||
|
|
resultMessage.value = t('success_article_loaded');
|
|||
|
|
} catch (e) {
|
|||
|
|
resultIsError.value = true;
|
|||
|
|
resultMessage.value = t('error_loading', { error: e.message });
|
|||
|
|
} finally {
|
|||
|
|
isFetching.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const autofillMeta = async () => {
|
|||
|
|
isExtracting.value = true;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/extract-meta", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ content: form.content })
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
const m = data.meta || {};
|
|||
|
|
if (m.title) form.title = m.title;
|
|||
|
|
if (m.authors) form.authors = m.authors;
|
|||
|
|
if (m.description) form.description = m.description;
|
|||
|
|
if (m.site_name) form.site_name = m.site_name;
|
|||
|
|
if (m.date) form.date = (m.date || "").split("T")[0];
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(t('error_metadata', { error: e.message }));
|
|||
|
|
} finally {
|
|||
|
|
isExtracting.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const translateContent = async () => {
|
|||
|
|
isTranslating.value = true;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/translate", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ content: form.content, target_lang: targetLang.value })
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.detail || "Ошибка перевода");
|
|||
|
|
form.content = data.translated;
|
|||
|
|
} catch (e) {
|
|||
|
|
resultIsError.value = true;
|
|||
|
|
resultMessage.value = t('error_translation', { error: e.message });
|
|||
|
|
} finally {
|
|||
|
|
isTranslating.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const openPreview = async () => {
|
|||
|
|
isPreviewing.value = true;
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/preview", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
content: form.content, title: form.title, description: form.description,
|
|||
|
|
authors: form.authors, site_name: form.site_name, date: form.date,
|
|||
|
|
content_format: form.content_format
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.detail || "Ошибка предпросмотра");
|
|||
|
|
previewUrl.value = data.url;
|
|||
|
|
showPreview.value = true;
|
|||
|
|
} catch (e) {
|
|||
|
|
alert(t('error_preview', { error: e.message }));
|
|||
|
|
} finally {
|
|||
|
|
isPreviewing.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const submitBookmark = async () => {
|
|||
|
|
if (!form.title || !form.content) {
|
|||
|
|
resultIsError.value = true;
|
|||
|
|
resultMessage.value = t('error_title_required');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
isSubmitting.value = true;
|
|||
|
|
resultMessage.value = "";
|
|||
|
|
try {
|
|||
|
|
const payload = {
|
|||
|
|
...form,
|
|||
|
|
tags: tagsInput.value.split(",").map(t => t.trim()).filter(Boolean)
|
|||
|
|
};
|
|||
|
|
const res = await fetch("/api/submit", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(payload)
|
|||
|
|
});
|
|||
|
|
const data = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(data.detail || "Неизвестная ошибка сервера");
|
|||
|
|
|
|||
|
|
resultIsError.value = false;
|
|||
|
|
resultMessage.value = t('success_bookmark_created', { id: data.bookmark.id || 'скрыт' });
|
|||
|
|
// Черновик больше не нужен.
|
|||
|
|
localStorage.removeItem(DRAFT_KEY);
|
|||
|
|
} catch (e) {
|
|||
|
|
resultIsError.value = true;
|
|||
|
|
resultMessage.value = t('error_submit', { error: e.message });
|
|||
|
|
} finally {
|
|||
|
|
isSubmitting.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
showSettings, showPreview, isTranslating, isSubmitting, isFetching,
|
|||
|
|
isExtracting, isPreviewing, isTesting, targetLang, tagsInput, urlInput,
|
|||
|
|
previewUrl, dragActive, isDark, languages, formats,
|
|||
|
|
settings, form, charCount, wordCount,
|
|||
|
|
resultMessage, resultIsError, testMessage, testIsError,
|
|||
|
|
toggleDark, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
|
|||
|
|
autofillMeta, translateContent, openPreview, submitBookmark,
|
|||
|
|
availableLanguages, currentLang, t, changeLanguage
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}).mount('#app');
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# ==========================================
|
|||
|
|
# БЭКЕНД: ЭНДПОИНТЫ API
|
|||
|
|
# ==========================================
|
|||
|
|
|
|||
|
|
@app.get("/", response_class=HTMLResponse)
|
|||
|
|
def index():
|
|||
|
|
return HTMLResponse(HTML_TEMPLATE)
|
|||
|
|
|
|||
|
|
@app.get("/api/settings")
|
|||
|
|
def get_settings():
|
|||
|
|
return load_config()
|
|||
|
|
|
|||
|
|
@app.post("/api/settings")
|
|||
|
|
def update_settings(settings: SettingsModel):
|
|||
|
|
config_dict = settings.model_dump() if hasattr(settings, "model_dump") else settings.dict()
|
|||
|
|
save_config(config_dict)
|
|||
|
|
return {"status": "ok"}
|
|||
|
|
|
|||
|
|
@app.get("/api/languages")
|
|||
|
|
def get_languages():
|
|||
|
|
"""Возвращает список доступных языков интерфейса."""
|
|||
|
|
return {"languages": get_available_languages()}
|
|||
|
|
|
|||
|
|
@app.get("/api/language/{lang_code}")
|
|||
|
|
def get_language(lang_code: str):
|
|||
|
|
"""Возвращает строки локализации для указанного языка."""
|
|||
|
|
translations = load_language(lang_code)
|
|||
|
|
if not translations:
|
|||
|
|
raise HTTPException(404, f"Язык {lang_code} не найден")
|
|||
|
|
return translations
|
|||
|
|
|
|||
|
|
@app.post("/api/upload")
|
|||
|
|
async def upload_file(file: UploadFile = File(...)):
|
|||
|
|
content = await file.read()
|
|||
|
|
for enc in ["utf-8", "windows-1251", "latin-1"]:
|
|||
|
|
try:
|
|||
|
|
text = content.decode(enc)
|
|||
|
|
return {"content": text}
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
continue
|
|||
|
|
raise HTTPException(400, "Невозможно прочитать кодировку файла.")
|
|||
|
|
|
|||
|
|
@app.post("/api/translate")
|
|||
|
|
def translate_api(req: TranslateRequest):
|
|||
|
|
soup = BeautifulSoup(req.content, "html.parser")
|
|||
|
|
|
|||
|
|
# Простой текст без тегов — переводим целиком с учётом лимита Google.
|
|||
|
|
if not soup.find():
|
|||
|
|
return {"translated": translate_long(req.content, req.target_lang)}
|
|||
|
|
|
|||
|
|
translator = GoogleTranslator(source='auto', target=req.target_lang)
|
|||
|
|
nodes_to_translate = []
|
|||
|
|
texts = []
|
|||
|
|
|
|||
|
|
for node in soup.find_all(string=True):
|
|||
|
|
if node.parent.name not in ['style', 'script', 'head'] and node.strip():
|
|||
|
|
nodes_to_translate.append(node)
|
|||
|
|
texts.append(node.strip())
|
|||
|
|
|
|||
|
|
if not texts:
|
|||
|
|
return {"translated": req.content}
|
|||
|
|
|
|||
|
|
# Группируем текстовые узлы в батчи так, чтобы суммарная длина батча
|
|||
|
|
# не превышала лимит Google на один запрос. Длинные узлы переводим
|
|||
|
|
# по отдельности через translate_long (он сам режет на части).
|
|||
|
|
translated_texts = []
|
|||
|
|
batch, batch_len = [], 0
|
|||
|
|
|
|||
|
|
def flush_batch():
|
|||
|
|
nonlocal batch, batch_len
|
|||
|
|
if not batch:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
res = translator.translate_batch(batch)
|
|||
|
|
translated_texts.extend(res)
|
|||
|
|
except Exception:
|
|||
|
|
for text in batch:
|
|||
|
|
try:
|
|||
|
|
translated_texts.append(translator.translate(text))
|
|||
|
|
except Exception:
|
|||
|
|
translated_texts.append(text)
|
|||
|
|
batch, batch_len = [], 0
|
|||
|
|
|
|||
|
|
for text in texts:
|
|||
|
|
if len(text) > TRANSLATE_CHAR_LIMIT:
|
|||
|
|
flush_batch()
|
|||
|
|
translated_texts.append(translate_long(text, req.target_lang))
|
|||
|
|
continue
|
|||
|
|
# +1 на разделитель; держим батч под лимитом и не длиннее 20 узлов.
|
|||
|
|
if batch_len + len(text) + 1 > TRANSLATE_CHAR_LIMIT or len(batch) >= 20:
|
|||
|
|
flush_batch()
|
|||
|
|
batch.append(text)
|
|||
|
|
batch_len += len(text) + 1
|
|||
|
|
flush_batch()
|
|||
|
|
|
|||
|
|
for i, node in enumerate(nodes_to_translate):
|
|||
|
|
if i < len(translated_texts) and translated_texts[i]:
|
|||
|
|
node.replace_with(translated_texts[i])
|
|||
|
|
|
|||
|
|
return {"translated": str(soup)}
|
|||
|
|
|
|||
|
|
@app.post("/api/fetch-url")
|
|||
|
|
async def fetch_url(req: FetchUrlRequest):
|
|||
|
|
"""Скачивает страницу по URL и извлекает из неё чистый текст статьи + метаданные."""
|
|||
|
|
url = req.url.strip()
|
|||
|
|
if not url:
|
|||
|
|
raise HTTPException(400, "URL не указан.")
|
|||
|
|
if not url.startswith(("http://", "https://")):
|
|||
|
|
url = "https://" + url
|
|||
|
|
if trafilatura is None:
|
|||
|
|
raise HTTPException(500, "Библиотека trafilatura не установлена (pip install trafilatura).")
|
|||
|
|
|
|||
|
|
headers = {"User-Agent": "Mozilla/5.0 (compatible; ReadeckImporter/1.0)"}
|
|||
|
|
try:
|
|||
|
|
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0, headers=headers) as client:
|
|||
|
|
resp = await client.get(url)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
raw_html = resp.text
|
|||
|
|
except Exception as e:
|
|||
|
|
raise HTTPException(400, f"Не удалось загрузить страницу: {type(e).__name__}: {e}")
|
|||
|
|
|
|||
|
|
# Извлекаем основной контент как HTML (с заголовками/ссылками/картинками).
|
|||
|
|
extracted = trafilatura.extract(
|
|||
|
|
raw_html, output_format="html", include_links=True,
|
|||
|
|
include_images=True, include_formatting=True, url=url,
|
|||
|
|
)
|
|||
|
|
content = sanitize_html(extracted) if extracted else ""
|
|||
|
|
|
|||
|
|
meta = extract_metadata_from_html(raw_html)
|
|||
|
|
if not content:
|
|||
|
|
# Фолбэк: если ничего не извлеклось — отдадим хотя бы текст body.
|
|||
|
|
soup = BeautifulSoup(raw_html, "html.parser")
|
|||
|
|
body = soup.body
|
|||
|
|
content = sanitize_html(str(body)) if body else ""
|
|||
|
|
|
|||
|
|
return {"content": content, "meta": meta}
|
|||
|
|
|
|||
|
|
@app.post("/api/extract-meta")
|
|||
|
|
def extract_meta(req: ExtractMetaRequest):
|
|||
|
|
"""Возвращает метаданные, найденные в переданном HTML."""
|
|||
|
|
return {"meta": extract_metadata_from_html(req.content)}
|
|||
|
|
|
|||
|
|
@app.post("/api/render-markdown")
|
|||
|
|
def render_markdown(req: MarkdownRequest):
|
|||
|
|
"""Конвертирует Markdown в HTML для предпросмотра/отправки."""
|
|||
|
|
return {"html": markdown_to_html(req.content)}
|
|||
|
|
|
|||
|
|
@app.post("/api/test-connection")
|
|||
|
|
async def test_connection(settings: SettingsModel):
|
|||
|
|
"""Проверяет доступность Readeck и валидность токена."""
|
|||
|
|
readeck_url = settings.readeck_url.strip().strip("/")
|
|||
|
|
readeck_token = settings.readeck_token.strip()
|
|||
|
|
if not readeck_url or not readeck_token:
|
|||
|
|
raise HTTPException(400, "Заполните URL и токен.")
|
|||
|
|
|
|||
|
|
headers = {"Authorization": f"Bearer {readeck_token}"}
|
|||
|
|
try:
|
|||
|
|
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client:
|
|||
|
|
resp = await client.get(f"{readeck_url}/api/bookmarks?limit=1", headers=headers)
|
|||
|
|
except Exception as e:
|
|||
|
|
raise HTTPException(400, f"Нет связи с сервером: {type(e).__name__}: {e}")
|
|||
|
|
|
|||
|
|
if resp.status_code in (401, 403):
|
|||
|
|
raise HTTPException(401, "Сервер доступен, но токен отклонён (401/403).")
|
|||
|
|
if resp.status_code >= 400:
|
|||
|
|
raise HTTPException(400, f"Сервер ответил кодом {resp.status_code}.")
|
|||
|
|
return {"ok": True, "message": "Подключение успешно: сервер и токен валидны."}
|
|||
|
|
|
|||
|
|
def prepare_content(raw: str, content_format: str) -> str:
|
|||
|
|
"""Готовит контент к публикации: markdown->html, затем санитизация."""
|
|||
|
|
fmt = (content_format or "html").lower()
|
|||
|
|
if fmt == "markdown":
|
|||
|
|
html = markdown_to_html(raw)
|
|||
|
|
elif fmt == "text":
|
|||
|
|
# Экранируем и сохраняем переводы строк как параграфы.
|
|||
|
|
escaped = bleach.clean(raw, tags=[], strip=True)
|
|||
|
|
paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()]
|
|||
|
|
html = "".join(f"<p>{p}</p>" for p in paragraphs) or f"<p>{escaped}</p>"
|
|||
|
|
else:
|
|||
|
|
html = raw
|
|||
|
|
return sanitize_html(html)
|
|||
|
|
|
|||
|
|
def inject_metadata(html_content: str, meta: dict) -> str:
|
|||
|
|
soup = BeautifulSoup(html_content, "html.parser")
|
|||
|
|
|
|||
|
|
if not soup.html:
|
|||
|
|
wrapper = BeautifulSoup("<!DOCTYPE html><html><head></head><body></body></html>", "html.parser")
|
|||
|
|
wrapper.body.append(soup)
|
|||
|
|
soup = wrapper
|
|||
|
|
elif not soup.head:
|
|||
|
|
head = soup.new_tag("head")
|
|||
|
|
soup.html.insert(0, head)
|
|||
|
|
|
|||
|
|
soup.html["lang"] = meta.get("language", "ru")
|
|||
|
|
|
|||
|
|
def set_meta(attrs: dict):
|
|||
|
|
search_attrs = {k: v for k, v in attrs.items() if k != "content"}
|
|||
|
|
tag = soup.head.find("meta", attrs=search_attrs)
|
|||
|
|
if tag:
|
|||
|
|
tag["content"] = attrs["content"]
|
|||
|
|
else:
|
|||
|
|
new_tag = soup.new_tag("meta")
|
|||
|
|
new_tag.attrs.update(attrs)
|
|||
|
|
soup.head.append(new_tag)
|
|||
|
|
|
|||
|
|
if meta.get("title"):
|
|||
|
|
if soup.head.title:
|
|||
|
|
soup.head.title.string = meta["title"]
|
|||
|
|
else:
|
|||
|
|
t_tag = soup.new_tag("title")
|
|||
|
|
t_tag.string = meta["title"]
|
|||
|
|
soup.head.append(t_tag)
|
|||
|
|
|
|||
|
|
if meta.get("description"):
|
|||
|
|
set_meta({"name": "description", "content": meta["description"]})
|
|||
|
|
if meta.get("authors"):
|
|||
|
|
set_meta({"name": "author", "content": meta["authors"]})
|
|||
|
|
if meta.get("site_name"):
|
|||
|
|
set_meta({"property": "og:site_name", "content": meta["site_name"]})
|
|||
|
|
if meta.get("date"):
|
|||
|
|
set_meta({"name": "article:published_time", "content": meta["date"]})
|
|||
|
|
|
|||
|
|
return str(soup)
|
|||
|
|
|
|||
|
|
@app.post("/api/submit")
|
|||
|
|
async def submit_bookmark(req: SubmitRequest):
|
|||
|
|
config = load_config()
|
|||
|
|
readeck_url = config.get("readeck_url", "").strip("/")
|
|||
|
|
readeck_token = config.get("readeck_token", "")
|
|||
|
|
public_host = config.get("public_host", "").strip() or get_lan_ip()
|
|||
|
|
|
|||
|
|
if not readeck_url or not readeck_token:
|
|||
|
|
raise HTTPException(400, "URL и Токен Readeck не настроены. Откройте настройки и сохраните их.")
|
|||
|
|
|
|||
|
|
prepared = prepare_content(req.content, req.content_format)
|
|||
|
|
final_html = inject_metadata(prepared, {
|
|||
|
|
"title": req.title,
|
|||
|
|
"description": req.description,
|
|||
|
|
"authors": req.authors,
|
|||
|
|
"site_name": req.site_name,
|
|||
|
|
"date": req.date,
|
|||
|
|
"language": req.language
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
content_id = store_content(final_html)
|
|||
|
|
|
|||
|
|
callback_url = f"http://{public_host}:{PORT}/content/{content_id}"
|
|||
|
|
|
|||
|
|
payload = {
|
|||
|
|
"url": callback_url,
|
|||
|
|
"labels": req.tags,
|
|||
|
|
"favorite": req.favorite,
|
|||
|
|
"archived": req.archive
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
headers = {
|
|||
|
|
"Authorization": f"Bearer {readeck_token}",
|
|||
|
|
"Content-Type": "application/json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
print(f"\n[DEBUG] --- НАЧАЛО ОТПРАВКИ ---")
|
|||
|
|
print(f"[DEBUG] URL: {readeck_url}/api/bookmarks")
|
|||
|
|
|
|||
|
|
async with httpx.AsyncClient() as client:
|
|||
|
|
try:
|
|||
|
|
resp = await client.post(
|
|||
|
|
f"{readeck_url}/api/bookmarks",
|
|||
|
|
json=payload,
|
|||
|
|
headers=headers,
|
|||
|
|
timeout=45.0,
|
|||
|
|
follow_redirects=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print(f"[DEBUG] Статус ответа: {resp.status_code}")
|
|||
|
|
|
|||
|
|
if resp.status_code >= 400:
|
|||
|
|
raise Exception(f"Readeck отклонил запрос (Код {resp.status_code}). Ответ: {resp.text}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data = resp.json()
|
|||
|
|
except Exception:
|
|||
|
|
data = {"id": "Успешно, но сервер не вернул JSON"}
|
|||
|
|
|
|||
|
|
print(f"[DEBUG] --- УСПЕШНО --- \n")
|
|||
|
|
return {"success": True, "bookmark": data}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print("\n!!! ОШИБКА READECK API !!!")
|
|||
|
|
traceback.print_exc()
|
|||
|
|
print("!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")
|
|||
|
|
raise HTTPException(500, detail=f"{type(e).__name__}: {str(e)}")
|
|||
|
|
|
|||
|
|
class PreviewRequest(BaseModel):
|
|||
|
|
content: str
|
|||
|
|
title: str = ""
|
|||
|
|
description: str = ""
|
|||
|
|
authors: str = ""
|
|||
|
|
site_name: str = ""
|
|||
|
|
date: str = ""
|
|||
|
|
language: str = "ru"
|
|||
|
|
content_format: str = "html"
|
|||
|
|
|
|||
|
|
@app.post("/api/preview")
|
|||
|
|
def preview(req: PreviewRequest):
|
|||
|
|
"""Готовит финальный HTML (как при отправке) и кладёт во временное хранилище
|
|||
|
|
для просмотра через /content/{id} — точно так же, как его увидит Readeck."""
|
|||
|
|
prepared = prepare_content(req.content, req.content_format)
|
|||
|
|
final_html = inject_metadata(prepared, {
|
|||
|
|
"title": req.title,
|
|||
|
|
"description": req.description,
|
|||
|
|
"authors": req.authors,
|
|||
|
|
"site_name": req.site_name,
|
|||
|
|
"date": req.date,
|
|||
|
|
"language": req.language,
|
|||
|
|
})
|
|||
|
|
content_id = store_content(final_html)
|
|||
|
|
return {"id": content_id, "url": f"/content/{content_id}"}
|
|||
|
|
|
|||
|
|
@app.get("/content/{content_id}")
|
|||
|
|
def get_content(content_id: str):
|
|||
|
|
entry = CONTENT_STORE.get(content_id)
|
|||
|
|
if not entry or time.time() - entry["created"] > CONTENT_TTL:
|
|||
|
|
CONTENT_STORE.pop(content_id, None)
|
|||
|
|
raise HTTPException(404, "Content not found or expired")
|
|||
|
|
return HTMLResponse(content=entry["html"], media_type="text/html; charset=utf-8")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
print(f"[*] Starting Readeck Local Importer on http://0.0.0.0:{PORT}")
|
|||
|
|
print(f"[*] Your LAN IP (for firewall/callback) is: {get_lan_ip()}")
|
|||
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info", reload=False)
|