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
+91 -50
View File
@@ -1,83 +1,124 @@
# Readeck Local Importer # Readeck Importer
Локальный веб-сервис для импорта статей в [Readeck](https://readeck.org/) (self-hosted сервис «прочитать позже»). Позволяет загрузить текст, файл или статью по ссылке, перевести её, отредактировать метаданные и одной кнопкой создать закладку в Readeck. Локальное веб-приложение для добавления статей и файлов в [Readeck](https://readeck.org/) self-hosted сервис «прочитать позже».
Readeck умеет сохранять закладки только по URL, поэтому приложение поднимает временную ссылку на ваш контент в локальной сети и передаёт её Readeck — так локальный текст попадает в библиотеку как обычная статья. Readeck импортирует материалы по URL. Приложение подготавливает контент, временно публикует его по доступному в локальной сети адресу и создаёт закладку через Readeck API. Поэтому в библиотеку можно отправлять и локальные файлы.
## Возможности ## Возможности
- **Импорт по ссылке** — скачивает страницу и извлекает чистый текст статьи (`trafilatura`). - Импорт статьи по URL с извлечением основного содержания (`trafilatura`).
- **Загрузка файлов** `.txt`, `.html`, `.md` (+ drag & drop), автоопределение кодировки. - Загрузка файлов `.txt`, `.html`, `.htm`, `.md`, `.markdown`, включая drag & drop.
- **Форматы контента** — HTML, Markdown, простой текст. - Автоопределение UTF-8 и Windows-1251 для локальных файлов.
- **Перевод** через Google (22 языка) с учётом лимитов на длину запроса. - Форматы содержимого: HTML, Markdown и простой текст.
- **Автозаполнение метаданных** из HTML-метатегов (заголовок, автор, описание, дата, сайт). - Перевод через Google Translate с разбиением длинного текста на безопасные части.
- **Предпросмотр** статьи ровно в том виде, в каком её увидит Readeck. - Автозаполнение метаданных: заголовок, автор, описание, дата и сайт.
- **Санитизация HTML** перед публикацией (`bleach`). - Предпросмотр итоговой статьи перед отправкой.
- **Тест подключения** к Readeck прямо из настроек. - Санитизация HTML перед публикацией (`bleach`).
- **Локализация интерфейса** — поддержка нескольких языков с возможностью добавления новых. - Проверка соединения с Readeck в настройках.
- Тёмная тема, счётчик символов/слов, автосохранение черновика. - Локальная библиотека: отдельное окно для просмотра файлов из выбранной папки.
- Отправка открытого в Локальной библиотеке файла в основное окно для последующей отправки в Readeck.
- Светлая и тёмная темы, автосохранение черновика, счётчики символов и слов.
- Интерфейс на русском, английском и казахском языках.
## Требования ## Требования
- Python 3.9+ - Python 3.9+ (проект проверялся на Python 3.12).
- Доступный сервер Readeck и API-токен к нему - Доступный сервер Readeck и API-токен.
- Сетевой доступ Readeck к машине с этим приложением, если Readeck расположен на другом хосте.
## Установка ## Установка
```bash ### Windows: быстрый запуск
pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura
В каталоге проекта есть `run.bat`. Он запускает приложение через `.venv\Scripts\python.exe` и очищает унаследованные `PYTHONPATH` / `PYTHONHOME`.
Перед первым запуском создайте виртуальное окружение и установите зависимости:
```bat
py -3.12 -m venv .venv
.venv\Scripts\python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart
run.bat
``` ```
## Запуск ### Универсальный запуск
```bash ```bash
python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart
python main.py python main.py
``` ```
Сервер стартует на `http://0.0.0.0:8142`, браузер откроется автоматически на `http://127.0.0.1:8142`. Сервис слушает `0.0.0.0:8142`, а браузер автоматически открывает `http://127.0.0.1:8142`.
При первом запуске откроется окно настроек — укажите: ## Первоначальная настройка
- **Readeck URL** — адрес вашего сервера Readeck (например `http://192.168.1.10:8000`) При первом запуске откроется окно **Настройки**. Укажите:
- **API Токен** — токен из настроек Readeck (`Bearer`)
- **LAN IP** — IP этой машины в локальной сети (для callback-ссылки, по которой Readeck заберёт контент)
Нажмите «Проверить подключение», чтобы убедиться, что сервер и токен валидны, затем сохраните. Настройки записываются в `config.json`. - **Readeck URL** — адрес Readeck, например `http://192.168.1.10:8000`.
- **API Токен** — токен Readeck; приложение передаёт его в заголовке `Authorization: Bearer …`.
- **Ваш LAN IP** — IP машины с приложением, доступный для Readeck. Он используется в callback-ссылке с материалом.
- **Папка локальной библиотеки** — папка, в которой нужно искать файлы для чтения.
- **Язык интерфейса**.
## Использование Нажмите **Проверить подключение**, затем **Сохранить**. Настройки записываются в `config.json`.
1. Вставьте текст, загрузите/перетащите файл или импортируйте статью по ссылке. ## Работа с материалами
2. При необходимости переведите контент и выберите его формат.
3. Заполните или автозаполните метаданные, добавьте теги.
4. Посмотрите предпросмотр и нажмите «Создать закладку».
## Файлы 1. В основном окне вставьте текст, загрузите файл или введите URL статьи.
2. При необходимости выберите формат и переведите содержимое.
3. Заполните метаданные вручную либо используйте **Автозаполнение**.
4. Добавьте теги и нужные флаги Readeck.
5. Откройте предпросмотр и нажмите **Создать закладку**.
- `main.py` — всё приложение (бэкенд FastAPI + фронтенд на Vue 3 / Tailwind). ### Локальная библиотека
- `config.json` — настройки подключения к Readeck и выбранный язык интерфейса.
- `lang/` — папка с файлами локализации интерфейса. 1. Откройте **Локальная библиотека** в шапке основного окна.
2. В отдельном окне появится список поддерживаемых файлов из выбранной папки и всех её подпапок.
3. Нажмите **Открыть**, чтобы перейти в режим чтения. Файлы не загружаются целиком на странице списка, поэтому библиотека остаётся отзывчивой и с крупными каталогами.
4. На странице чтения используйте **Отправить в Readeck**. Содержимое и заголовок будут переданы в главное окно приложения.
5. Проверьте метаданные и создайте закладку обычным способом.
Поддерживаются `.txt`, `.md`, `.markdown`, `.html` и `.htm`. Переход за пределы выбранной директории заблокирован.
## Тестирование
Регрессионные тесты не требуют сети или настоящего Readeck:
```bash
# Windows Git Bash / POSIX shell
PYTHONPATH= PYTHONHOME= .venv/Scripts/python.exe -m unittest tests.test_reader -v
# Windows cmd.exe
set PYTHONPATH=
set PYTHONHOME=
.venv\Scripts\python.exe -m unittest tests.test_reader -v
```
Проверяются настройки Локальной библиотеки, безопасное открытие файлов, передача текста в основное окно и наличие общей дизайн-системы интерфейса.
## Структура проекта
- `main.py` — FastAPI-бэкенд, API и встроенный интерфейс Vue 3 / Tailwind.
- `run.bat` — запуск приложения в Windows.
- `config.json` — локальные настройки подключения и Локальной библиотеки. **Не храните здесь рабочие токены в репозитории.**
- `lang/` — локализации интерфейса.
- `tests/test_reader.py` — автоматические тесты Локальной библиотеки и UI.
- `LOCALIZATION.md` — сведения о локализации.
## Локализация ## Локализация
Приложение поддерживает несколько языков интерфейса. Доступные языки: Доступны:
- 🇬🇧 English
- 🇷🇺 Русский
- 🇰🇿 Қазақша (Казахский)
### Смена языка - English
1. Откройте настройки (⚙️) - Русский
2. Выберите язык в списке "🌍 Язык интерфейса" - Қазақша
3. Язык изменится мгновенно
### Добавление нового языка Чтобы добавить язык, создайте `lang/<код>.json`, скопируйте структуру `lang/en.json` или `lang/ru.json`, переведите значения без изменения ключей и перезапустите приложение.
1. Создайте файл `lang/код_языка.json` (например, `de.json`)
2. Скопируйте структуру из `lang/en.json` или `lang/ru.json`
3. Переведите все значения (не меняя ключи)
4. Перезапустите приложение — новый язык появится автоматически!
Подробнее см. `lang/README.md` и `LOCALIZATION.md`. Подробнее: `lang/README.md` и `LOCALIZATION.md`.
## Примечания по безопасности ## Безопасность
- `config.json` хранит API-токен в открытом виде. Не коммитьте файл в git; при необходимости перевыпустите токен. - `config.json` содержит API-токен в открытом виде. Не публикуйте реальный токен и перевыпустите его при утечке.
- Сервис слушает `0.0.0.0:8142` **без аутентификации** и доступен всем в локальной сети. Эндпоинт импорта по URL скачивает произвольные адреса (потенциальный SSRF). Для домашней сети это обычно приемлемо; не выставляйте сервис в интернет без авторизации. - Сервис доступен в локальной сети на порту `8142`, без собственной аутентификации.
- Импорт по URL загружает произвольный адрес, то есть потенциально допускает SSRF. Не публикуйте приложение в интернете без ограничения доступа и дополнительной защиты.
- Readeck должен иметь возможность обратиться по callback-адресу к указанному LAN IP и порту `8142`.
+173 -32
View File
@@ -8,6 +8,8 @@ import threading
import webbrowser import webbrowser
import contextlib import contextlib
import traceback import traceback
import html
from pathlib import Path
import uvicorn import uvicorn
from typing import List from typing import List
@@ -190,9 +192,11 @@ def load_config() -> dict:
"readeck_url": "", "readeck_url": "",
"readeck_token": "", "readeck_token": "",
"public_host": get_lan_ip(), "public_host": get_lan_ip(),
"language": "ru" "language": "ru",
# Папка, которую просматривает отдельное окно «Локальная библиотека».
"reader_directory": os.path.join(BASE_DIR, "pages")
} }
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f: with open(CONFIG_FILE, "r", encoding="utf-8") as f:
@@ -239,6 +243,7 @@ class SettingsModel(BaseModel):
readeck_token: str = "" readeck_token: str = ""
public_host: str = "" public_host: str = ""
language: str = "ru" language: str = "ru"
reader_directory: str = ""
class TranslateRequest(BaseModel): class TranslateRequest(BaseModel):
content: str content: str
@@ -266,6 +271,81 @@ class SubmitRequest(BaseModel):
archive: bool = False archive: bool = False
content_format: str = "html" # html | markdown | text 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) # ФРОНТЕНД (HTML / JS)
# ========================================== # ==========================================
@@ -285,8 +365,7 @@ HTML_TEMPLATE = """
* { font-family: 'Inter', sans-serif; } * { font-family: 'Inter', sans-serif; }
body { body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); background: #f5f7f5;
background-attachment: fixed;
} }
.glass { .glass {
@@ -302,15 +381,12 @@ HTML_TEMPLATE = """
} }
.gradient-text { .gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #17211b;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.btn-gradient { .btn-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: #176b45;
transition: all 0.3s ease;
} }
.btn-gradient:hover:not(:disabled) { .btn-gradient:hover:not(:disabled) {
@@ -422,32 +498,66 @@ HTML_TEMPLATE = """
animation: spin 0.7s linear infinite; animation: spin 0.7s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } @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> </style>
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<div id="app" class="max-w-7xl mx-auto p-6 md:p-8"> <div id="app" class="app-shell mx-auto">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 fade-in"> <div class="app-header flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div> <div>
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-2 drop-shadow-lg"> <h1 class="app-title font-extrabold mb-2">
📚 {{ t('app_title') }} {{ t('app_title') }}
</h1> </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>
<div class="flex gap-3"> <div class="top-actions flex gap-2 flex-wrap">
<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')"> <button @click="toggleDark" class="top-action glass font-semibold" :title="isDark ? t('theme_light') : t('theme_dark')">
{{ isDark ? '☀️' : '🌙' }} {{ isDark ? 'Светлая тема' : 'Тёмная тема' }}
</button> </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"> <button @click="openLocalLibrary" class="top-action glass font-semibold">
⚙️ {{ t('settings') }} Локальная библиотека
</button>
<button @click="showSettings = true" class="top-action glass font-semibold">
{{ t('settings') }}
</button> </button>
</div> </div>
</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="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 1
</div> </div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_upload') }}</h2> <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" <textarea v-model="form.content" rows="14"
@dragover.prevent="dragActive = true" @dragleave.prevent="dragActive = false" @drop.prevent="onDrop" @dragover.prevent="dragActive = true" @dragleave.prevent="dragActive = false" @drop.prevent="onDrop"
:class="{ 'drag-active': dragActive }" :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> :placeholder="t('content_placeholder')"></textarea>
</div> </div>
@@ -522,10 +632,10 @@ HTML_TEMPLATE = """
</div> </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 justify-between gap-3 mb-6">
<div class="flex items-center gap-3"> <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 2
</div> </div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_metadata') }}</h2> <h2 class="text-2xl font-bold gradient-text">{{ t('section_metadata') }}</h2>
@@ -560,7 +670,7 @@ HTML_TEMPLATE = """
</div> </div>
<div class="flex items-center gap-3 mb-6"> <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 3
</div> </div>
<h2 class="text-2xl font-bold gradient-text">{{ t('section_readeck') }}</h2> <h2 class="text-2xl font-bold gradient-text">{{ t('section_readeck') }}</h2>
@@ -593,10 +703,10 @@ HTML_TEMPLATE = """
</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 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="glass-dark p-8 rounded-3xl w-full max-w-md shadow-2xl"> <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="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> </div>
<h2 class="text-2xl font-bold gradient-text">{{ t('settings_title') }}</h2> <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> <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')"> <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>
<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"> <div class="mb-6">
<label class="block text-sm font-bold mb-2 text-gray-700">{{ t('settings_language') }}</label> <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"> <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: "Текст" }, { 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({ const form = reactive({
content: "", title: "", description: "", authors: "", content: "", title: "", description: "", authors: "",
site_name: "Local Importer", date: new Date().toISOString().split('T')[0], 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"); 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 () => { onMounted(async () => {
// Загрузка доступных языков // Загрузка доступных языков
try { try {
@@ -1032,7 +1161,7 @@ HTML_TEMPLATE = """
previewUrl, dragActive, isDark, languages, formats, previewUrl, dragActive, isDark, languages, formats,
settings, form, charCount, wordCount, settings, form, charCount, wordCount,
resultMessage, resultIsError, testMessage, testIsError, resultMessage, resultIsError, testMessage, testIsError,
toggleDark, saveSettings, testConnection, uploadFile, onDrop, fetchUrl, toggleDark, openLocalLibrary, saveSettings, testConnection, uploadFile, onDrop, fetchUrl,
autofillMeta, translateContent, openPreview, submitBookmark, autofillMeta, translateContent, openPreview, submitBookmark,
availableLanguages, currentLang, t, changeLanguage availableLanguages, currentLang, t, changeLanguage
}; };
@@ -1051,6 +1180,18 @@ HTML_TEMPLATE = """
def index(): def index():
return HTMLResponse(HTML_TEMPLATE) 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") @app.get("/api/settings")
def get_settings(): def get_settings():
return load_config() return load_config()
+28
View File
@@ -0,0 +1,28 @@
@echo off
setlocal
cd /d "%~dp0"
rem Prevent an inherited PYTHONPATH/PYTHONHOME from another application
rem (for example Hermes) from loading packages outside this project's .venv.
set "PYTHONPATH="
set "PYTHONHOME="
if not exist ".venv\Scripts\python.exe" (
echo [ERROR] Virtual environment .venv was not found.
echo Create it and install dependencies first:
echo py -3.12 -m venv .venv
echo .venv\Scripts\python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart
pause
exit /b 1
)
".venv\Scripts\python.exe" main.py
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] Application stopped with code %EXIT_CODE%.
pause
)
exit /b %EXIT_CODE%
+72
View File
@@ -0,0 +1,72 @@
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "main.py"
spec = importlib.util.spec_from_file_location("toreadeck_main", MODULE_PATH)
main = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main)
class LocalLibraryTests(unittest.TestCase):
def test_default_config_has_reader_directory(self):
with patch.object(main, "CONFIG_FILE", "missing-config.json"):
config = main.load_config()
self.assertEqual(config["reader_directory"], str(Path(main.BASE_DIR) / "pages"))
def test_library_lists_files_without_loading_their_contents(self):
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
path = Path(directory)
(path / "note.txt").write_text("Очень большой текст файла", encoding="utf-8")
with patch.object(main, "load_config", return_value={"reader_directory": str(path)}):
response = main.reader_window()
body = response.body.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Локальная библиотека", body)
self.assertIn("note.txt", body)
self.assertIn("Открыть", body)
self.assertNotIn("Очень большой текст файла", body)
self.assertNotIn("<textarea", body)
def test_reader_page_displays_file_and_can_send_it_to_importer(self):
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
path = Path(directory)
(path / "note.txt").write_text("Текст заметки", encoding="utf-8")
with patch.object(main, "load_config", return_value={"reader_directory": str(path)}):
response = main.reader_file("note.txt")
body = response.body.decode("utf-8")
self.assertEqual(response.status_code, 200)
self.assertIn("Текст заметки", body)
self.assertIn("Отправить в Readeck", body)
self.assertIn("window.opener.postMessage", body)
self.assertNotIn("<textarea", body)
class ApplicationDesignTests(unittest.TestCase):
def test_main_ui_uses_the_calm_workspace_design_system(self):
page = main.HTML_TEMPLATE
self.assertIn("--workspace", page)
self.assertIn("--surface", page)
self.assertIn(".app-shell", page)
self.assertIn(".workspace-card", page)
self.assertNotIn("background: linear-gradient(135deg, #667eea", page)
def test_library_and_reader_use_shared_reader_design_tokens(self):
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
path = Path(directory)
(path / "note.txt").write_text("Текст", encoding="utf-8")
with patch.object(main, "load_config", return_value={"reader_directory": str(path)}):
library = main.reader_window().body.decode("utf-8")
reader = main.reader_file("note.txt").body.decode("utf-8")
self.assertIn("--workspace", library)
self.assertIn("--workspace", reader)
self.assertIn("reader-topbar", reader)
if __name__ == "__main__":
unittest.main()