2026-05-31 18:45:40 +08:00
import os
import re
import json
import time
import uuid
import socket
import threading
import webbrowser
import contextlib
import traceback
2026-07-15 20:19:54 +08:00
import html
from pathlib import Path
2026-05-31 18:45:40 +08:00
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 ( ) ,
2026-07-15 20:19:54 +08:00
" language " : " ru " ,
# Папка, которую просматривает отдельное окно «Локальная библиотека».
" reader_directory " : os . path . join ( BASE_DIR , " pages " )
2026-05-31 18:45:40 +08:00
}
2026-07-15 20:19:54 +08:00
2026-05-31 18:45:40 +08:00
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 "
2026-07-15 20:19:54 +08:00
reader_directory : str = " "
2026-05-31 18:45:40 +08:00
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
2026-07-15 20:19:54 +08:00
# ==========================================
# ЛОКАЛЬНАЯ БИБЛИОТЕКА
# ==========================================
def get_reader_directory ( ) - > Path :
configured = load_config ( ) . get ( " reader_directory " , " " )
return Path ( configured or os . path . join ( BASE_DIR , " pages " ) ) . expanduser ( ) . resolve ( )
def get_reader_file ( relative_path : str ) - > Path :
""" Возвращает файл из библиотеки и запрещает выход за выбранный каталог. """
directory = get_reader_directory ( )
target = ( directory / relative_path ) . resolve ( )
if not target . is_relative_to ( directory ) or not target . is_file ( ) :
raise HTTPException ( 404 , " Файл не найден. " )
if target . suffix . lower ( ) not in { " .txt " , " .md " , " .markdown " , " .html " , " .htm " } :
raise HTTPException ( 400 , " Этот тип файла не поддерживается. " )
return target
def read_reader_file ( path : Path ) - > str :
for encoding in ( " utf-8 " , " windows-1251 " ) :
try :
return path . read_text ( encoding = encoding )
except UnicodeDecodeError :
continue
except OSError as exc :
raise HTTPException ( 500 , f " Не удалось прочитать файл: { exc } " )
raise HTTPException ( 400 , " Не удалось определить кодировку файла. " )
def build_reader_window ( ) - > str :
""" Строит лёгкий список файлов — их содержимое загружается только при открытии. """
directory = get_reader_directory ( )
files = [ ]
error = " "
if not directory . is_dir ( ) :
error = f " Каталог не найден: { directory } "
else :
try :
files = sorted (
( path for path in directory . rglob ( " * " ) if path . is_file ( ) and path . suffix . lower ( ) in { " .txt " , " .md " , " .markdown " , " .html " , " .htm " } ) ,
key = lambda path : str ( path . relative_to ( directory ) ) . lower ( ) ,
)
except OSError as exc :
error = f " Не удалось прочитать каталог: { exc } "
rows = [ ]
for path in files :
relative_name = str ( path . relative_to ( directory ) ) . replace ( " \\ " , " / " )
modified = time . strftime ( " % Y- % m- %d % H: % M " , time . localtime ( path . stat ( ) . st_mtime ) )
rows . append ( f ''' <a class= " list-item " href= " /library/file/ { relative_name } " >
<span class= " item-name " > { ' 🌐 ' if path . suffix . lower ( ) in { ' .html ' , ' .htm ' } else ' 📄 ' } { html . escape ( relative_name ) } </span>
<span class= " item-meta " > { html . escape ( path . suffix . upper ( ) ) } · { modified } </span>
<span class= " open-hint " >Открыть →</span>
</a> ''' )
listing = " " . join ( rows ) or " <p class= ' empty ' >В выбранном каталоге нет поддерживаемых файлов.</p> "
notice = f " <p class= ' error ' > { html . escape ( error ) } </p> " if error else " "
return f ''' <!doctype html><html lang= " ru " ><head><meta charset= " utf-8 " ><meta name= " viewport " content= " width=device-width, initial-scale=1 " >
<title>Локальная библиотека</title><style>
:root {{ --workspace:#f5f7f5;--surface:#fff;--surface-muted:#f0f3f0;--ink:#17211b;--muted:#617066;--line:#d9e0da;--accent:#176b45;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 4px 12px rgb(23 33 27 / .06) }} @media(prefers-color-scheme:dark) {{ :root {{ --workspace:#111714;--surface:#18201b;--surface-muted:#202a23;--ink:#edf5ef;--muted:#a4b4a8;--line:#324036;--accent:#48a875;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 8px 18px rgb(0 0 0 / .2) }} }} body {{ font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;line-height:1.6 }} .container {{ max-width:900px;margin:0 auto;padding:40px 20px }} h1 {{ font-weight:800;letter-spacing:-.025em }} .subtitle {{ color:var(--muted);margin-bottom:2rem }} .list-view {{ display:flex;flex-direction:column;gap:10px }} .list-item {{ background:var(--card);padding:16px 20px;border-radius:12px;border:1px solid var(--border);box-shadow:var(--shadow);text-decoration:none;color:inherit;display:flex;align-items:center;justify-content:space-between;gap:16px;transition:transform .2s,border-color .2s }} .list-item:hover {{ transform:translateY(-2px);border-color:var(--accent) }} .item-name {{ font-weight:700;word-break:break-all }} .item-meta {{ color:var(--muted);font-size:.85rem;white-space:nowrap }} .open-hint {{ color:var(--accent);font-size:.9rem;font-weight:600;white-space:nowrap }} .footer {{ margin-top:60px;padding:20px;text-align:center;font-size:.85rem;color:var(--muted);border-top:1px solid var(--border) }} code {{ background:rgb(0 0 0 / .1);padding:2px 4px;border-radius:4px }} .error {{ color:#dc2626;font-weight:600 }} .empty {{ color:var(--muted) }} </style></head><body><div class= " container " >
<h1>📄 Локальная библиотека</h1><p class= " subtitle " >Текущая папка: <code> { html . escape ( str ( directory ) ) } </code></p> { notice } <div class= " list-view " > { listing } </div><div class= " footer " >Выберите файл, чтобы открыть его для чтения.</div></div></body></html> '''
def build_reader_file_page ( relative_path : str ) - > str :
path = get_reader_file ( relative_path )
content = read_reader_file ( path )
directory = get_reader_directory ( )
is_html = path . suffix . lower ( ) in { " .html " , " .htm " }
displayed = content if is_html else f " <pre> { html . escape ( content ) } </pre> "
return f ''' <!doctype html><html lang= " ru " ><head><meta charset= " utf-8 " ><meta name= " viewport " content= " width=device-width, initial-scale=1 " ><title> { html . escape ( path . name ) } </title><style>
:root {{ --workspace:#f5f7f5;--surface:#fff;--surface-muted:#f0f3f0;--ink:#17211b;--muted:#617066;--line:#d9e0da;--accent:#176b45;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 4px 12px rgb(23 33 27 / .06) }} @media(prefers-color-scheme:dark) {{ :root {{ --workspace:#111714;--surface:#18201b;--surface-muted:#202a23;--ink:#edf5ef;--muted:#a4b4a8;--line:#324036;--accent:#48a875;--bg:var(--workspace);--card:var(--surface);--text:var(--ink);--border:var(--line);--shadow:0 8px 18px rgb(0 0 0 / .2) }} }} body {{ font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text);margin:0;line-height:1.6 }} .container {{ max-width:900px;margin:0 auto;padding:40px 20px }} .reader-topbar {{ display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px }} .content-wrapper {{ background:var(--card);padding:40px;border-radius:24px;border:1px solid var(--border);box-shadow:var(--shadow) }} .back-btn {{ display:inline-block;color:var(--accent);text-decoration:none;font-weight:600 }} pre {{ white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;font-size:.95rem;background:rgb(0 0 0 / .03);padding:20px;border-radius:12px;border:1px solid var(--border) }} .send {{ background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 14px;font-weight:650;cursor:pointer }} </style></head><body><div class= " container " ><div class= " reader-topbar " ><a href= " /library " class= " back-btn " >← Назад к библиотеке</a><button class= " send " id= " send " >Отправить в Readeck</button></div><div class= " content-wrapper " ><h1> { html . escape ( path . name ) } </h1><hr> { displayed } </div></div><script>document.getElementById( ' send ' ).addEventListener( ' click ' , () => {{ if (!window.opener || window.opener.closed) {{ alert( ' Главное окно Readeck не найдено. ' );return; }} window.opener.postMessage( {{ type: ' local-library-content ' ,title: { json . dumps ( path . stem ) } ,content: { json . dumps ( content ) } ,content_format: { json . dumps ( ' html ' if is_html else ' text ' ) } }} ,window.location.origin); document.getElementById( ' send ' ).textContent= ' Отправлено ' ; }} );</script></body></html> '''
2026-05-31 18:45:40 +08:00
# ==========================================
# ФРОНТЕНД (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 {
2026-07-15 20:19:54 +08:00
background: #f5f7f5;
2026-05-31 18:45:40 +08:00
}
.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 {
2026-07-15 20:19:54 +08:00
color: #17211b;
2026-05-31 18:45:40 +08:00
}
.btn-gradient {
2026-07-15 20:19:54 +08:00
background: #176b45;
2026-05-31 18:45:40 +08:00
}
.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); } }
2026-07-15 20:19:54 +08:00
/* Claude-design workspace system: Operate surface */
:root { --workspace:#f5f7f5; --surface:#ffffff; --surface-muted:#f0f3f0; --ink:#17211b; --muted:#617066; --line:#d9e0da; --accent:#176b45; --accent-strong:#0e5133; --focus:#8bc7a8; --danger:#b42318; --radius:14px; }
* { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, " Segoe UI " , sans-serif; }
body { background:var(--workspace); color:var(--ink); }
.app-shell { max-width:1440px; padding:28px 32px 48px; }
.app-header { padding-bottom:22px; border-bottom:1px solid var(--line); margin-bottom:24px; }
.app-title { color:var(--ink)!important; text-shadow:none!important; filter:none!important; font-size:clamp(1.7rem,3vw,2.35rem)!important; letter-spacing:-.04em; }
.app-subtitle { color:var(--muted)!important; }
.workspace-grid { gap:20px!important; align-items:start; }
.workspace-card { background:var(--surface)!important; border:1px solid var(--line)!important; border-radius:var(--radius)!important; box-shadow:none!important; padding:26px!important; }
.workspace-card:hover { transform:none!important; box-shadow:0 7px 22px rgba(23,33,27,.06)!important; }
.workspace-card h2 { color:var(--ink)!important; -webkit-text-fill-color:var(--ink)!important; background:none!important; font-size:1.25rem!important; letter-spacing:-.02em; }
.step-number { background:var(--surface-muted)!important; color:var(--accent)!important; box-shadow:none!important; border:1px solid var(--line); border-radius:50 % !important; width:34px!important; height:34px!important; font-size:.85rem!important; }
.glass, .glass-dark { background:var(--surface-muted)!important; border:1px solid var(--line)!important; backdrop-filter:none!important; box-shadow:none!important; color:var(--ink)!important; }
.top-action { min-height:42px; padding:0 14px!important; border-radius:9px!important; font-size:.875rem; }
.input-modern { background:var(--surface)!important; color:var(--ink)!important; border:1px solid var(--line)!important; border-radius:9px!important; box-shadow:none!important; }
.input-modern:focus { border-color:var(--accent)!important; box-shadow:0 0 0 3px rgba(23,107,69,.16)!important; }
.btn-gradient, .btn-success { background:var(--accent)!important; color:#fff!important; box-shadow:none!important; border-radius:9px!important; }
.btn-gradient:hover:not(:disabled), .btn-success:hover:not(:disabled) { background:var(--accent-strong)!important; transform:none!important; box-shadow:none!important; }
button:disabled { opacity:.45!important; }
.editor-area { min-height:360px!important; resize:vertical!important; line-height:1.55; }
.format-control { border-radius:8px!important; box-shadow:none!important; }
.quiet-action { border-radius:9px!important; }
.settings-dialog, .preview-dialog { background:var(--surface)!important; border:1px solid var(--line); border-radius:16px!important; box-shadow:0 24px 70px rgba(23,33,27,.22)!important; }
.settings-dialog h2, .preview-dialog h2 { color:var(--ink)!important; -webkit-text-fill-color:var(--ink)!important; background:none!important; }
.settings-label { color:var(--ink)!important; font-size:.875rem!important; letter-spacing:0!important; text-transform:none!important; }
html.dark { --workspace:#111714; --surface:#18201b; --surface-muted:#202a23; --ink:#edf5ef; --muted:#a4b4a8; --line:#324036; --accent:#48a875; --accent-strong:#66bc8e; --focus:#91d4ad; }
html.dark .text-gray-700, html.dark .text-gray-800, html.dark .text-gray-900 { color:var(--ink)!important; }
html.dark .text-gray-500 { color:var(--muted)!important; }
@media (max-width: 640px) { .app-shell { padding:18px 14px 30px; } .workspace-card { padding:18px!important; } .app-header { align-items:stretch!important; } .top-actions { width:100 % ; } .top-action { flex:1; } }
2026-05-31 18:45:40 +08:00
</style>
</head>
<body class= " min-h-screen " >
2026-07-15 20:19:54 +08:00
<div id= " app " class= " app-shell mx-auto " >
<div class= " app-header flex flex-col md:flex-row justify-between items-start md:items-center gap-4 " >
2026-05-31 18:45:40 +08:00
<div>
2026-07-15 20:19:54 +08:00
<h1 class= " app-title font-extrabold mb-2 " >
{{ t( ' app_title ' ) }}
2026-05-31 18:45:40 +08:00
</h1>
2026-07-15 20:19:54 +08:00
<p class= " app-subtitle text-sm md:text-base " > {{ t( ' app_subtitle ' ) }}</p>
2026-05-31 18:45:40 +08:00
</div>
2026-07-15 20:19:54 +08:00
<div class= " top-actions flex gap-2 flex-wrap " >
<button @click= " toggleDark " class= " top-action glass font-semibold " :title= " isDark ? t( ' theme_light ' ) : t( ' theme_dark ' ) " >
{{ isDark ? ' Светлая тема ' : ' Тёмная тема ' }}
</button>
<button @click= " openLocalLibrary " class= " top-action glass font-semibold " >
Локальная библиотека
2026-05-31 18:45:40 +08:00
</button>
2026-07-15 20:19:54 +08:00
<button @click= " showSettings = true " class= " top-action glass font-semibold " >
{{ t( ' settings ' ) }}
2026-05-31 18:45:40 +08:00
</button>
</div>
</div>
2026-07-15 20:19:54 +08:00
<div class= " workspace-grid grid grid-cols-1 lg:grid-cols-2 " >
2026-05-31 18:45:40 +08:00
<!-- Левая панель: Загрузка и контент -->
2026-07-15 20:19:54 +08:00
<div class= " workspace-card " >
2026-05-31 18:45:40 +08:00
<div class= " flex items-center gap-3 mb-6 " >
2026-07-15 20:19:54 +08:00
<div class= " step-number flex items-center justify-center font-bold " >
2026-05-31 18:45:40 +08:00
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 } "
2026-07-15 20:19:54 +08:00
class= " editor-area w-full input-modern p-4 font-mono text-sm transition-all "
2026-05-31 18:45:40 +08:00
: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>
<!-- Правая панель: Метаданные и отправка -->
2026-07-15 20:19:54 +08:00
<div class= " workspace-card " >
2026-05-31 18:45:40 +08:00
<div class= " flex items-center justify-between gap-3 mb-6 " >
<div class= " flex items-center gap-3 " >
2026-07-15 20:19:54 +08:00
<div class= " step-number flex items-center justify-center font-bold " >
2026-05-31 18:45:40 +08:00
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 " >
2026-07-15 20:19:54 +08:00
<div class= " step-number flex items-center justify-center font-bold " >
2026-05-31 18:45:40 +08:00
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>
<!-- Модальное окно настроек -->
2026-07-15 20:19:54 +08:00
<div v-if= " showSettings " class= " fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 " @click.self= " showSettings = false " >
<div class= " settings-dialog p-6 md:p-8 w-full max-w-md " >
2026-05-31 18:45:40 +08:00
<div class= " flex items-center gap-3 mb-6 " >
2026-07-15 20:19:54 +08:00
<div class= " step-number flex items-center justify-center font-bold " >
2026-05-31 18:45:40 +08:00
⚙️
</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>
2026-07-15 20:19:54 +08:00
<div class= " mb-4 " >
<label class= " block text-sm font-bold mb-2 text-gray-700 " >Папка локальной библиотеки</label>
<input v-model= " settings.reader_directory " type= " text " class= " w-full input-modern rounded-xl p-3 text-sm " placeholder= " C: \\ путь \\ к \\ файлам " >
<p class= " mt-1 text-xs text-gray-500 " >Файлы .txt, .md и .html из этой папки будут показаны в отдельном окне.</p>
</div>
2026-05-31 18:45:40 +08:00
<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: " Текст " },
];
2026-07-15 20:19:54 +08:00
const settings = reactive( { readeck_url: " " , readeck_token: " " , public_host: " " , language: " ru " , reader_directory: " " });
2026-05-31 18:45:40 +08:00
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 " );
};
2026-07-15 20:19:54 +08:00
const openLocalLibrary = () => {
const popup = window.open( " /library " , " local-library " , " width=1100,height=800,resizable=yes,scrollbars=yes " );
if (!popup) alert( " Браузер заблокировал новое окно. Разрешите всплывающие окна для этого приложения. " );
};
window.addEventListener( " message " , (event) => {
if (event.origin !== window.location.origin || !event.data || event.data.type !== " local-library-content " ) return;
form.content = event.data.content || " " ;
form.content_format = event.data.content_format || " text " ;
if (event.data.title) form.title = event.data.title;
resultIsError.value = false;
resultMessage.value = " Текст получен из Локальной библиотеки. " ;
});
2026-05-31 18:45:40 +08:00
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,
2026-07-15 20:19:54 +08:00
toggleDark, openLocalLibrary, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
2026-05-31 18:45:40 +08:00
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 )
2026-07-15 20:19:54 +08:00
@app.get ( " /library " , response_class = HTMLResponse )
def reader_window ( ) :
""" Открывает Локальную библиотеку в отдельном окне браузера. """
return HTMLResponse ( build_reader_window ( ) )
@app.get ( " /library/file/ { relative_path:path} " , response_class = HTMLResponse )
def reader_file ( relative_path : str ) :
""" Открывает выбранный файл в режиме чтения. """
return HTMLResponse ( build_reader_file_page ( relative_path ) )
2026-05-31 18:45:40 +08:00
@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 )