feat: add local library and refresh interface

This commit is contained in:
dinlo
2026-07-15 20:19:54 +08:00
parent e1b2485156
commit a8b0b4f34e
4 changed files with 364 additions and 82 deletions
+173 -32
View File
@@ -8,6 +8,8 @@ import threading
import webbrowser
import contextlib
import traceback
import html
from pathlib import Path
import uvicorn
from typing import List
@@ -190,9 +192,11 @@ def load_config() -> dict:
"readeck_url": "",
"readeck_token": "",
"public_host": get_lan_ip(),
"language": "ru"
"language": "ru",
# Папка, которую просматривает отдельное окно «Локальная библиотека».
"reader_directory": os.path.join(BASE_DIR, "pages")
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
@@ -239,6 +243,7 @@ class SettingsModel(BaseModel):
readeck_token: str = ""
public_host: str = ""
language: str = "ru"
reader_directory: str = ""
class TranslateRequest(BaseModel):
content: str
@@ -266,6 +271,81 @@ class SubmitRequest(BaseModel):
archive: bool = False
content_format: str = "html" # html | markdown | text
# ==========================================
# ЛОКАЛЬНАЯ БИБЛИОТЕКА
# ==========================================
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>'''
# ==========================================
# ФРОНТЕНД (HTML / JS)
# ==========================================
@@ -285,8 +365,7 @@ HTML_TEMPLATE = """
* { font-family: 'Inter', sans-serif; }
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-attachment: fixed;
background: #f5f7f5;
}
.glass {
@@ -302,15 +381,12 @@ HTML_TEMPLATE = """
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: #17211b;
}
.btn-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: all 0.3s ease;
background: #176b45;
}
.btn-gradient:hover:not(:disabled) {
@@ -422,32 +498,66 @@ HTML_TEMPLATE = """
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 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; } }
</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 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">
<div>
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-2 drop-shadow-lg">
📚 {{ t('app_title') }}
<h1 class="app-title font-extrabold mb-2">
{{ t('app_title') }}
</h1>
<p class="text-white/80 text-sm md:text-base">{{ t('app_subtitle') }}</p>
<p class="app-subtitle 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 ? '☀️' : '🌙' }}
<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="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 @click="openLocalLibrary" class="top-action glass font-semibold">
Локальная библиотека
</button>
<button @click="showSettings = true" class="top-action glass font-semibold">
{{ t('settings') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8">
<div class="workspace-grid grid grid-cols-1 lg:grid-cols-2">
<!-- Левая панель: Загрузка и контент -->
<div class="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
<div class="workspace-card">
<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">
<div class="step-number flex items-center justify-center font-bold">
1
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_upload') }}</h2>
@@ -500,7 +610,7 @@ HTML_TEMPLATE = """
<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"
class="editor-area w-full input-modern p-4 font-mono text-sm transition-all"
:placeholder="t('content_placeholder')"></textarea>
</div>
@@ -522,10 +632,10 @@ HTML_TEMPLATE = """
</div>
<!-- Правая панель: Метаданные и отправка -->
<div class="glass-dark p-6 md:p-8 rounded-3xl shadow-2xl card-hover fade-in">
<div class="workspace-card">
<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">
<div class="step-number flex items-center justify-center font-bold">
2
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_metadata') }}</h2>
@@ -560,7 +670,7 @@ HTML_TEMPLATE = """
</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">
<div class="step-number flex items-center justify-center font-bold">
3
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_readeck') }}</h2>
@@ -593,10 +703,10 @@ HTML_TEMPLATE = """
</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 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">
<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 class="step-number flex items-center justify-center font-bold">
⚙️
</div>
<h2 class="text-2xl font-bold gradient-text">{{ t('settings_title') }}</h2>
@@ -614,6 +724,11 @@ HTML_TEMPLATE = """
<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-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>
<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">
@@ -715,7 +830,7 @@ HTML_TEMPLATE = """
{ value: "text", label: "Текст" },
];
const settings = reactive({ readeck_url: "", readeck_token: "", public_host: "", language: "ru" });
const settings = reactive({ readeck_url: "", readeck_token: "", public_host: "", language: "ru", reader_directory: "" });
const form = reactive({
content: "", title: "", description: "", authors: "",
site_name: "Local Importer", date: new Date().toISOString().split('T')[0],
@@ -776,6 +891,20 @@ HTML_TEMPLATE = """
localStorage.setItem("readeck_theme", isDark.value ? "dark" : "light");
};
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 = "Текст получен из Локальной библиотеки.";
});
onMounted(async () => {
// Загрузка доступных языков
try {
@@ -1032,7 +1161,7 @@ HTML_TEMPLATE = """
previewUrl, dragActive, isDark, languages, formats,
settings, form, charCount, wordCount,
resultMessage, resultIsError, testMessage, testIsError,
toggleDark, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
toggleDark, openLocalLibrary, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
autofillMeta, translateContent, openPreview, submitBookmark,
availableLanguages, currentLang, t, changeLanguage
};
@@ -1051,6 +1180,18 @@ HTML_TEMPLATE = """
def index():
return HTMLResponse(HTML_TEMPLATE)
@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))
@app.get("/api/settings")
def get_settings():
return load_config()