feat: add local library and refresh interface
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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%
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user