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`).
|
- Импорт статьи по 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`.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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