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`).
- **Загрузка файлов** `.txt`, `.html`, `.md` (+ drag & drop), автоопределение кодировки.
- **Форматы контента** — HTML, Markdown, простой текст.
- **Перевод** через Google (22 языка) с учётом лимитов на длину запроса.
- **Автозаполнение метаданных** из HTML-метатегов (заголовок, автор, описание, дата, сайт).
- **Предпросмотр** статьи ровно в том виде, в каком её увидит Readeck.
- **Санитизация HTML** перед публикацией (`bleach`).
- **Тест подключения** к Readeck прямо из настроек.
- **Локализация интерфейса** — поддержка нескольких языков с возможностью добавления новых.
- Тёмная тема, счётчик символов/слов, автосохранение черновика.
- Импорт статьи по URL с извлечением основного содержания (`trafilatura`).
- Загрузка файлов `.txt`, `.html`, `.htm`, `.md`, `.markdown`, включая drag & drop.
- Автоопределение UTF-8 и Windows-1251 для локальных файлов.
- Форматы содержимого: HTML, Markdown и простой текст.
- Перевод через Google Translate с разбиением длинного текста на безопасные части.
- Автозаполнение метаданных: заголовок, автор, описание, дата и сайт.
- Предпросмотр итоговой статьи перед отправкой.
- Санитизация HTML перед публикацией (`bleach`).
- Проверка соединения с Readeck в настройках.
- Локальная библиотека: отдельное окно для просмотра файлов из выбранной папки.
- Отправка открытого в Локальной библиотеке файла в основное окно для последующей отправки в Readeck.
- Светлая и тёмная темы, автосохранение черновика, счётчики символов и слов.
- Интерфейс на русском, английском и казахском языках.
## Требования
- Python 3.9+
- Доступный сервер Readeck и API-токен к нему
- Python 3.9+ (проект проверялся на Python 3.12).
- Доступный сервер Readeck и API-токен.
- Сетевой доступ Readeck к машине с этим приложением, если Readeck расположен на другом хосте.
## Установка
```bash
pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura
### Windows: быстрый запуск
В каталоге проекта есть `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
python -m pip install fastapi uvicorn pydantic beautifulsoup4 lxml httpx deep-translator markdown bleach trafilatura python-multipart
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
- 🇷🇺 Русский
- 🇰🇿 Қазақша (Казахский)
Доступны:
### Смена языка
1. Откройте настройки (⚙️)
2. Выберите язык в списке "🌍 Язык интерфейса"
3. Язык изменится мгновенно
- English
- Русский
- Қазақша
### Добавление нового языка
1. Создайте файл `lang/код_языка.json` (например, `de.json`)
2. Скопируйте структуру из `lang/en.json` или `lang/ru.json`
3. Переведите все значения (не меняя ключи)
4. Перезапустите приложение — новый язык появится автоматически!
Чтобы добавить язык, создайте `lang/<код>.json`, скопируйте структуру `lang/en.json` или `lang/ru.json`, переведите значения без изменения ключей и перезапустите приложение.
Подробнее см. `lang/README.md` и `LOCALIZATION.md`.
Подробнее: `lang/README.md` и `LOCALIZATION.md`.
## Примечания по безопасности
## Безопасность
- `config.json` хранит API-токен в открытом виде. Не коммитьте файл в git; при необходимости перевыпустите токен.
- Сервис слушает `0.0.0.0:8142` **без аутентификации** и доступен всем в локальной сети. Эндпоинт импорта по URL скачивает произвольные адреса (потенциальный SSRF). Для домашней сети это обычно приемлемо; не выставляйте сервис в интернет без авторизации.
- `config.json` содержит API-токен в открытом виде. Не публикуйте реальный токен и перевыпустите его при утечке.
- Сервис доступен в локальной сети на порту `8142`, без собственной аутентификации.
- Импорт по URL загружает произвольный адрес, то есть потенциально допускает SSRF. Не публикуйте приложение в интернете без ограничения доступа и дополнительной защиты.
- Readeck должен иметь возможность обратиться по callback-адресу к указанному LAN IP и порту `8142`.
+172 -31
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,7 +192,9 @@ 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):
@@ -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()
+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()