commit b2d9deb4b4795eef32af278108b7c5b957ed61ec Author: Egor Deev Date: Tue Sep 29 14:40:15 2026 +0300 declaude: конвертер экспорта Claude.ai в статический HTML-архив diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..345e7cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +claude-backup-v1/ +claude_archive/ diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..255d04f --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,536 @@ +# Claude Archive Converter — интеграция в FastAPI сайт + +## Что нужно сделать + +Встроить модуль конвертации Claude-бэкапа в статический HTML-архив как endpoint FastAPI-сайта. + +**Пользовательский сценарий:** +1. Пользователь открывает страницу `/tools/claude-archive` +2. Перетаскивает или выбирает zip-файл (скачанный с claude.ai — экспорт данных) +3. Видит прогресс-бар обработки +4. Получает ссылку для скачивания готового zip-архива с HTML-страницами +5. Файл автоматически удаляется с сервера через 10 минут + +--- + +## Ядро конвертера + +Файл `declaude.py` уже существует и полностью работоспособен. Его **не нужно переписывать** — нужно только обернуть в веб-слой. + +Ключевые классы из `declaude.py`: +- `DataLoader(source_dir)` — загружает `conversations.json`, `projects/*.json`, `users.json` +- `MappingManager(path)` — читает/пишет `mapping.json` +- `SiteBuilder(output_dir, loader, mapping)` — генерирует всё HTML +- `SiteBuilder.build_all()` — запускает полную генерацию + +Пример минимального вызова (уже работает локально): +```python +from declaude import DataLoader, MappingManager, SiteBuilder +from pathlib import Path + +loader = DataLoader(Path("extracted_backup/")) +loader.load() +mapping = MappingManager(Path("mapping.json")) +mapping.load() +site = SiteBuilder(Path("output/"), loader, mapping) +site.build_all() +``` + +--- + +## Структура файлов, которые нужно создать + +``` +your_project/ +├── routers/ +│ └── claude_archive.py # новый роутер FastAPI +├── services/ +│ └── claude_converter.py # бизнес-логика: распаковка, запуск, упаковка +├── templates/ +│ └── claude_archive.html # страница загрузки (Jinja2 или отдельный HTML) +├── static/ +│ └── claude_archive.js # фронтенд: upload, polling, progress bar +└── declaude.py # уже существует — НЕ ТРОГАТЬ +``` + +Подключи роутер в `main.py`: +```python +from routers.claude_archive import router as claude_archive_router +app.include_router(claude_archive_router, prefix="/tools") +``` + +--- + +## routers/claude_archive.py + +```python +import asyncio +import uuid +import zipfile +import shutil +from pathlib import Path +from typing import Dict +from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks +from fastapi.responses import FileResponse, JSONResponse + +router = APIRouter(tags=["claude-archive"]) + +# Временная директория для задач +WORK_DIR = Path("/tmp/claude_archive_jobs") +WORK_DIR.mkdir(parents=True, exist_ok=True) + +# Хранилище состояний задач в памяти (для prod замени на Redis) +jobs: Dict[str, dict] = {} + + +@router.get("/claude-archive") +async def page(): + """Страница загрузки — отдаёт HTML.""" + from fastapi.responses import HTMLResponse + # Если используешь Jinja2, подключи TemplateResponse + html = Path("templates/claude_archive.html").read_text(encoding="utf-8") + return HTMLResponse(html) + + +@router.post("/claude-archive/upload") +async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...)): + """ + Принимает zip-файл, запускает конвертацию в фоне. + Возвращает job_id для polling статуса. + """ + if not file.filename.endswith(".zip"): + raise HTTPException(400, "Ожидается .zip файл") + + # Ограничение размера: 500 MB + MAX_SIZE = 500 * 1024 * 1024 + content = await file.read() + if len(content) > MAX_SIZE: + raise HTTPException(413, "Файл слишком большой (макс. 500 MB)") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + + # Сохраняем загруженный zip + upload_path = job_dir / "upload.zip" + upload_path.write_bytes(content) + + # Регистрируем задачу + jobs[job_id] = {"status": "queued", "progress": 0, "error": None} + + # Запускаем конвертацию в фоне + background_tasks.add_task(run_conversion, job_id, job_dir) + + return JSONResponse({"job_id": job_id}) + + +@router.get("/claude-archive/status/{job_id}") +async def status(job_id: str): + """Polling endpoint — возвращает статус задачи.""" + if job_id not in jobs: + raise HTTPException(404, "Задача не найдена") + return JSONResponse(jobs[job_id]) + + +@router.get("/claude-archive/download/{job_id}") +async def download(job_id: str): + """Скачать готовый архив.""" + if job_id not in jobs: + raise HTTPException(404, "Задача не найдена") + if jobs[job_id]["status"] != "done": + raise HTTPException(400, "Архив ещё не готов") + + result_zip = WORK_DIR / job_id / "result.zip" + if not result_zip.exists(): + raise HTTPException(404, "Файл не найден") + + return FileResponse( + path=str(result_zip), + media_type="application/zip", + filename="claude_archive.zip", + headers={"Content-Disposition": "attachment; filename=claude_archive.zip"} + ) +``` + +--- + +## services/claude_converter.py + +```python +import asyncio +import zipfile +import shutil +import traceback +from pathlib import Path +from routers.claude_archive import jobs, WORK_DIR + + +async def run_conversion(job_id: str, job_dir: Path): + """ + Фоновая задача: распаковать upload.zip, запустить declaude, упаковать результат. + Обновляет jobs[job_id] по ходу выполнения. + Удаляет все файлы через 10 минут после завершения. + """ + try: + jobs[job_id]["status"] = "extracting" + jobs[job_id]["progress"] = 5 + + # 1. Распаковываем загруженный архив + extract_dir = job_dir / "extracted" + extract_dir.mkdir() + with zipfile.ZipFile(job_dir / "upload.zip", "r") as zf: + zf.extractall(extract_dir) + + # Если внутри zip есть одна папка — используем её как source + children = list(extract_dir.iterdir()) + source_dir = children[0] if len(children) == 1 and children[0].is_dir() else extract_dir + + jobs[job_id]["status"] = "loading" + jobs[job_id]["progress"] = 15 + + # 2. Запускаем тяжёлую работу в thread pool (не блокируем event loop) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _build_archive, job_id, source_dir, job_dir) + + except Exception as e: + jobs[job_id]["status"] = "error" + jobs[job_id]["error"] = str(e) + traceback.print_exc() + finally: + # Удаляем файлы через 10 минут + await asyncio.sleep(600) + shutil.rmtree(job_dir, ignore_errors=True) + jobs.pop(job_id, None) + + +def _build_archive(job_id: str, source_dir: Path, job_dir: Path): + """ + Синхронная часть — выполняется в thread pool. + Импортируем declaude здесь чтобы не тащить в event loop. + """ + import sys + import os + # declaude.py лежит в корне проекта — добавляем в path если нужно + sys.path.insert(0, str(Path(__file__).parent.parent)) + + from declaude import DataLoader, MappingManager, SiteBuilder + + output_dir = job_dir / "output" + output_dir.mkdir() + + jobs[job_id]["status"] = "building" + jobs[job_id]["progress"] = 30 + + # Загружаем данные + loader = DataLoader(source_dir) + loader.load() + + # Пустой маппинг (пользователь может настроить потом через браузер) + mapping = MappingManager(job_dir / "mapping.json") + mapping.load() + + jobs[job_id]["progress"] = 50 + + # Генерируем HTML-архив + site = SiteBuilder(output_dir, loader, mapping) + site.build_all() + + jobs[job_id]["status"] = "packing" + jobs[job_id]["progress"] = 85 + + # Упаковываем результат + result_zip = job_dir / "result.zip" + _zip_directory(output_dir, result_zip) + + jobs[job_id]["status"] = "done" + jobs[job_id]["progress"] = 100 + + +def _zip_directory(source_dir: Path, output_path: Path): + """Рекурсивно запаковывает директорию в zip с относительными путями.""" + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in source_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(source_dir) + zf.write(file_path, arcname) +``` + +--- + +## templates/claude_archive.html + +Минимальная страница с drag-and-drop загрузкой и прогресс-баром. Адаптируй под свою систему шаблонов (Jinja2, etc.) и дизайн сайта. + +```html + + + + + + Claude Archive Converter + + + +
+

Claude Archive Converter

+

+ Загрузите zip-файл экспорта данных Claude (Settings → Privacy → Export data). + Вы получите красивый HTML-архив всех ваших чатов и проектов. +

+ +
+ +
+ + + + +
Перетащите .zip сюда или выберите файл
+
Максимум 500 MB
+
+
+ + + + + + +
+ + + + +``` + +--- + +## static/claude_archive.js + +```javascript +const BASE = "/tools/claude-archive"; + +// Drag & drop +const dropZone = document.getElementById("dropZone"); +const fileInput = document.getElementById("fileInput"); + +dropZone.addEventListener("click", () => fileInput.click()); +dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }); +dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over")); +dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + dropZone.classList.remove("drag-over"); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); +}); +fileInput.addEventListener("change", () => { + if (fileInput.files[0]) handleFile(fileInput.files[0]); +}); + +function handleFile(file) { + if (!file.name.endsWith(".zip")) { + showError("Ожидается файл .zip"); + return; + } + uploadFile(file); +} + +function uploadFile(file) { + showProgress(5, "Загрузка файла на сервер..."); + + const formData = new FormData(); + formData.append("file", file); + + fetch(`${BASE}/upload`, { method: "POST", body: formData }) + .then((r) => { + if (!r.ok) return r.json().then((d) => { throw new Error(d.detail || "Ошибка загрузки"); }); + return r.json(); + }) + .then((data) => pollStatus(data.job_id)) + .catch((err) => showError(err.message)); +} + +let pollTimer = null; + +function pollStatus(jobId) { + const STATUS_LABELS = { + queued: "В очереди...", + extracting: "Распаковка архива...", + loading: "Загрузка данных...", + building: "Генерация HTML-страниц...", + packing: "Упаковка результата...", + done: "Готово!", + error: "Ошибка", + }; + + fetch(`${BASE}/status/${jobId}`) + .then((r) => r.json()) + .then((data) => { + const label = STATUS_LABELS[data.status] || data.status; + showProgress(data.progress || 0, label); + + if (data.status === "done") { + showDone(jobId); + } else if (data.status === "error") { + showError(data.error || "Неизвестная ошибка"); + } else { + // Продолжаем polling каждые 1.5 секунды + pollTimer = setTimeout(() => pollStatus(jobId), 1500); + } + }) + .catch((err) => showError("Ошибка polling: " + err.message)); +} + +// UI helpers +function showProgress(pct, msg) { + document.getElementById("dropZone").style.display = "none"; + document.getElementById("progressSection").style.display = "block"; + document.getElementById("errorSection").style.display = "none"; + document.getElementById("doneSection").style.display = "none"; + document.getElementById("progressFill").style.width = pct + "%"; + document.getElementById("statusMsg").textContent = msg; +} + +function showDone(jobId) { + document.getElementById("progressSection").style.display = "none"; + document.getElementById("doneSection").style.display = "block"; + document.getElementById("downloadLink").href = `${BASE}/download/${jobId}`; +} + +function showError(msg) { + clearTimeout(pollTimer); + document.getElementById("progressSection").style.display = "none"; + document.getElementById("dropZone").style.display = "block"; + document.getElementById("errorSection").style.display = "block"; + document.getElementById("errorMsg").textContent = msg; +} + +function resetForm() { + document.getElementById("errorSection").style.display = "none"; + document.getElementById("doneSection").style.display = "none"; + document.getElementById("dropZone").style.display = "block"; + document.getElementById("progressFill").style.width = "0%"; +} +``` + +--- + +## Зависимости + +Ничего нового — всё уже есть в FastAPI. Только убедись что в `requirements.txt` есть: + +``` +fastapi +uvicorn +python-multipart # нужен для UploadFile +``` + +`python-multipart` нужен FastAPI для обработки `multipart/form-data` (загрузка файлов). Без него `UploadFile` не работает. + +--- + +## Важные решения и ограничения + +### Хранилище состояний задач +В коде выше `jobs` — это обычный Python dict в памяти. Это работает для **одного воркера**. Если у тебя несколько воркеров uvicorn (`--workers N`), состояние будет разделено между процессами и polling сломается. + +**Если используешь несколько воркеров:** замени `jobs` dict на Redis: +```python +import redis, json +r = redis.Redis() +# запись: r.setex(f"job:{job_id}", 700, json.dumps(state)) +# чтение: json.loads(r.get(f"job:{job_id}")) +``` + +### Очистка файлов +Временные файлы удаляются через `asyncio.sleep(600)` после завершения задачи. Если сервер перезапустится в процессе — файлы останутся. Добавь startup-хук для очистки старых папок: +```python +@app.on_event("startup") +async def cleanup_old_jobs(): + shutil.rmtree(WORK_DIR, ignore_errors=True) + WORK_DIR.mkdir(parents=True) +``` + +### Размер архива и таймауты +Conversations.json может весить 300+ MB. Обработка 950 диалогов занимает ~70 секунд. Убедись что у nginx/proxy нет таймаута на загрузку файла. Рекомендуемые настройки nginx: +```nginx +client_max_body_size 600M; +proxy_read_timeout 600s; +proxy_send_timeout 600s; +``` + +### Параллельные задачи +`run_in_executor` использует `ThreadPoolExecutor` по умолчанию (количество потоков = CPU * 5). При высокой нагрузке создай явный executor с ограничением: +```python +from concurrent.futures import ThreadPoolExecutor +executor = ThreadPoolExecutor(max_workers=3) +await loop.run_in_executor(executor, _build_archive, ...) +``` + +--- + +## Порядок реализации для Claude Code + +1. Скопировать `declaude.py` в корень проекта (или настроить `sys.path` в `claude_converter.py`) +2. Создать `routers/claude_archive.py` по шаблону выше +3. Создать `services/claude_converter.py`, импортировать `run_conversion` в роутер +4. Создать `templates/claude_archive.html` и `static/claude_archive.js` +5. Добавить `app.include_router(...)` в `main.py` +6. Добавить `python-multipart` в зависимости +7. Проверить что nginx разрешает загрузку файлов до 500 MB diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7aad128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Egor Deev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..92aff0d --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# declaude + +Автономная утилита для преобразования официального экспорта данных Claude.ai в интерактивный статический HTML-архив. + +Позволяет локально хранить, просматривать и быстро искать диалоги без внешних зависимостей и необходимости поднимать базы данных или веб-серверы. + +## Онлайн-версия + +Не хочется устанавливать Python и разбираться руками — есть готовая веб-версия: +**https://fe0.ru/declaude/**. Загружаете zip-экспорт, получаете готовый архив — без установки +чего-либо. Если важна приватность данных, запустите скрипт локально по инструкции ниже. + +## Возможности + +- **Полная автономность:** генерирует чистые HTML-страницы для каждого диалога, которые открываются в любом браузере. +- **Страницы проектов:** рендеринг контекстных документов и шаблонов системных промптов для каждого проекта. +- **Полноценная поддержка Markdown:** заголовки, списки, таблицы, цитаты, блоки кода. +- **Подсветка синтаксиса без сторонних библиотек:** встроенная поддержка Python, JavaScript, TypeScript, SQL, Bash, Go и Rust. +- **Темы оформления и языки:** переключение между светлой и тёмной темой, а также интерфейс на русском и английском языках (выбор сохраняется в LocalStorage). +- **Маппинг проектов:** возможность связывать диалоги с проектами прямо в браузере с выгрузкой файла `mapping.json` и быстрой пересборкой. +- **Инкрементальные обновления:** режим `--update` обновляет только добавленные или изменённые диалоги. +- **Нулевые зависимости:** работает исключительно на стандартной библиотеке Python 3.8+. + +## Подготовка данных + +1. Откройте **claude.ai → Settings → Privacy → Export data**. +2. Дождитесь письма со ссылкой и скачайте zip-архив с выгрузкой. +3. Распакуйте архив в отдельную папку. Внутри находятся файлы `conversations.json`, `users.json` и каталог `projects/`. + +## Использование + +### Первая сборка архива + +```bash +python declaude.py --build --source ./claude-export +``` + +После завершения откройте файл `claude_archive/index.html` в браузере. + +### Указание своей папки для архива + +```bash +python declaude.py --build --source ./claude-export --output ./my-archive +``` + +### Инкрементальное обновление из нового архива + +```bash +python declaude.py --update new_export.zip +``` + +Регенерируются только новые и отредактированные страницы, текущая привязка диалогов к проектам в `mapping.json` сохраняется. + +### Назначение диалога проекту через консоль + +```bash +python declaude.py --map +python declaude.py --remap +``` + +Быстрая пересборка страниц проектов (`--remap`) занимает секунды, так как не пересобирает неизменённые диалоги. + +## Структура сгенерированного архива + +``` +claude_archive/ +├── index.html # Главная страница: статистика, проекты, последние чаты +├── all_conversations.html # Полный список диалогов с поиском и фильтрацией +├── conversations/ +│ └── .html # Страницы отдельных диалогов +├── projects/ +│ └── / +│ └── index.html # Страницы проектов с документами и диалогами +└── assets/ + ├── style.css + └── app.js +``` + +## Параметры командной строки + +- `--build` — полная сборка архива из исходного каталога. +- `--source ПУТЬ` — путь к каталогу с распакованным экспортом (по умолчанию: `claude-backup-v1`). +- `--output ПУТЬ` — каталог для сохранения HTML-архива (по умолчанию: `claude_archive`). +- `--update АРХИВ.ZIP` — добавление данных из нового zip-архива с сохранением маппинга. +- `--remap` — быстрая пересборка индексной страницы и страниц проектов. +- `--map CONV_ID PROJ_ID` — ручное сопоставление диалога с проектом. +- `--mapping ПУТЬ` — путь к файлу сопоставлений (по умолчанию: `./mapping.json`). + +## Лицензия + +MIT diff --git a/declaude.py b/declaude.py new file mode 100644 index 0000000..4cad18d --- /dev/null +++ b/declaude.py @@ -0,0 +1,2098 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +declaude.py — Claude backup → static HTML archive + +Usage: + python declaude.py --build # first run, generate everything + python declaude.py --update path/to/new.zip # import new archive, keep mapping + python declaude.py --remap # regenerate project pages after editing mapping.json + python declaude.py --map # assign a conversation to a project +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +OUTPUT_DIR = Path("claude_archive") +MAPPING_FILE = Path("mapping.json") +META_FILE = OUTPUT_DIR / "_meta.json" + +BACKUP_CONVERSATIONS = "conversations.json" +BACKUP_USERS = "users.json" +BACKUP_PROJECTS_DIR = "projects" + + +# --------------------------------------------------------------------------- +# DataLoader +# --------------------------------------------------------------------------- + +class DataLoader: + """Loads and validates Claude backup data from a directory.""" + + def __init__(self, source_dir: Path): + self.source_dir = source_dir + self.conversations: List[Dict] = [] + self.projects: Dict[str, Dict] = {} # uuid → project + self.user: Dict = {} + + def load(self) -> None: + self._load_user() + self._load_projects() + self._load_conversations() + + def _load_user(self) -> None: + path = self.source_dir / BACKUP_USERS + if not path.exists(): + return + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list) and data: + self.user = data[0] + elif isinstance(data, dict): + self.user = data + except Exception as e: + print(f" Warning: could not load users.json: {e}") + + def _load_projects(self) -> None: + projects_dir = self.source_dir / BACKUP_PROJECTS_DIR + if not projects_dir.is_dir(): + print(f" Warning: projects/ directory not found in {self.source_dir}") + return + for json_file in projects_dir.glob("*.json"): + try: + with open(json_file, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "uuid" in data: + self.projects[data["uuid"]] = data + except Exception as e: + print(f" Warning: could not load {json_file.name}: {e}") + print(f" Loaded {len(self.projects)} projects") + + def _load_conversations(self) -> None: + path = self.source_dir / BACKUP_CONVERSATIONS + if not path.exists(): + print(f"Error: {path} not found.") + sys.exit(1) + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + print("Error: conversations.json is not a JSON array.") + sys.exit(1) + self.conversations = data + except Exception as e: + print(f"Error loading conversations.json: {e}") + sys.exit(1) + print(f" Loaded {len(self.conversations)} conversations") + + +# --------------------------------------------------------------------------- +# MappingManager +# --------------------------------------------------------------------------- + +class MappingManager: + """Manages conv_uuid → project_uuid mapping with persistence.""" + + def __init__(self, mapping_path: Path): + self.path = mapping_path + self.data: Dict[str, str] = {} # conv_uuid → project_uuid + + def load(self) -> None: + if not self.path.exists(): + return + try: + with open(self.path, encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, dict): + self.data = {str(k): str(v) for k, v in raw.items()} + except Exception as e: + print(f" Warning: could not load mapping.json: {e}") + + def save(self) -> None: + with open(self.path, "w", encoding="utf-8") as f: + json.dump(self.data, f, ensure_ascii=False, indent=2) + + def assign(self, conv_uuid: str, project_uuid: str) -> None: + self.data[conv_uuid] = project_uuid + + def get_project(self, conv_uuid: str) -> Optional[str]: + return self.data.get(conv_uuid) + + def conversations_for_project(self, project_uuid: str) -> List[str]: + return [c for c, p in self.data.items() if p == project_uuid] + + def merge_from_browser(self, browser_export: Dict[str, str]) -> int: + """Merge browser localStorage export into mapping. Returns count of new entries.""" + added = 0 + for conv_uuid, proj_uuid in browser_export.items(): + if conv_uuid not in self.data: + self.data[conv_uuid] = proj_uuid + added += 1 + return added + + +# --------------------------------------------------------------------------- +# MarkdownRenderer +# --------------------------------------------------------------------------- + +class MarkdownRenderer: + """ + Converts markdown text to HTML. + Handles: code blocks, inline code, headers, bold, italic, + links, images, horizontal rules, blockquotes, unordered/ordered lists. + No external dependencies. + """ + + # Syntax highlight token patterns per language family + _KEYWORDS = { + "python": r'\b(def|class|return|import|from|if|elif|else|for|while|with|as|in|not|and|or|is|None|True|False|try|except|finally|raise|yield|lambda|pass|break|continue|del|global|nonlocal|assert|async|await)\b', + "js": r'\b(function|const|let|var|return|if|else|for|while|do|class|new|this|import|export|default|from|async|await|try|catch|finally|throw|typeof|instanceof|in|of|true|false|null|undefined|switch|case|break|continue)\b', + "ts": r'\b(function|const|let|var|return|if|else|for|while|do|class|new|this|import|export|default|from|async|await|try|catch|finally|throw|typeof|instanceof|in|of|true|false|null|undefined|switch|case|break|continue|type|interface|enum|extends|implements|readonly|abstract)\b', + "html": None, + "css": None, + "sql": r'\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|HAVING|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|INDEX|DROP|ALTER|ADD|COLUMN|PRIMARY|KEY|FOREIGN|REFERENCES|NOT|NULL|UNIQUE|DEFAULT|AND|OR|AS|DISTINCT|LIMIT|OFFSET|COUNT|SUM|AVG|MAX|MIN|UNION|ALL|EXISTS|IN|LIKE|BETWEEN|CASE|WHEN|THEN|ELSE|END)\b', + "bash": r'\b(echo|if|then|else|fi|for|do|done|while|case|esac|function|return|exit|cd|ls|mkdir|rm|cp|mv|cat|grep|sed|awk|find|chmod|chown|export|source|alias|unset|set|read|printf|test|true|false)\b', + "go": r'\b(func|package|import|var|const|type|struct|interface|map|chan|go|defer|select|switch|case|default|return|if|else|for|range|break|continue|fallthrough|nil|true|false|make|new|len|cap|append|copy|delete|close|panic|recover)\b', + "rust": r'\b(fn|let|mut|pub|use|mod|struct|enum|impl|trait|for|while|loop|if|else|match|return|break|continue|true|false|None|Some|Ok|Err|self|Self|super|crate|move|ref|in|as|where|async|await|dyn|unsafe|extern)\b', + } + + _LANG_ALIASES = { + "javascript": "js", "typescript": "ts", "jsx": "js", "tsx": "ts", + "shell": "bash", "sh": "bash", "zsh": "bash", + "py": "python", "golang": "go", + } + + def render(self, text: str) -> str: + if not text: + return "" + + # 1. Extract and protect code blocks + code_blocks: List[str] = [] + text = re.sub( + r'```(\w*)\n?([\s\S]*?)```', + lambda m: self._store_code(m, code_blocks), + text + ) + + # 2. Escape HTML in the remaining text + text = self._escape_html(text) + + # 3. Inline code (protect from further processing) + inline_codes: List[str] = [] + text = re.sub( + r'`([^`\n]+)`', + lambda m: self._store_inline(m, inline_codes), + text + ) + + # 4a. Tables (before line-by-line block processing) + text = self._process_tables(text) + + # 4. Block-level elements (process line by line) + text = self._process_block(text) + + # 5. Inline formatting + text = self._process_inline(text) + + # 6. Restore inline code + for i, code in enumerate(inline_codes): + text = text.replace(f'\x02INLINE{i}\x03', code) + + # 7. Restore code blocks + for i, block in enumerate(code_blocks): + text = text.replace(f'\x02CODE{i}\x03', block) + + return text + + def _store_code(self, m: re.Match, store: List[str]) -> str: + lang_raw = m.group(1).strip().lower() + lang = self._LANG_ALIASES.get(lang_raw, lang_raw) + code = m.group(2) + highlighted = self._highlight(self._escape_html(code), lang) + lang_label = lang_raw or "code" + html = ( + f'
' + f'
' + f'{lang_label}' + f'' + f'
' + f'
{highlighted}
' + f'
' + ) + idx = len(store) + store.append(html) + return f'\x02CODE{idx}\x03' + + def _store_inline(self, m: re.Match, store: List[str]) -> str: + code = m.group(1) + html = f'{code}' + idx = len(store) + store.append(html) + return f'\x02INLINE{idx}\x03' + + def _highlight(self, code: str, lang: str) -> str: + if lang not in self._KEYWORDS: + return code + pattern = self._KEYWORDS[lang] + if not pattern: + return code + # Strings + code = re.sub(r'("(?:[^\\&#]|\\.)*?"|'(?:[^\\&#]|\\.)*?')', + r'\1', code) + # Keywords + code = re.sub(pattern, r'\1', code, + flags=re.IGNORECASE if lang == "sql" else 0) + # Numbers + code = re.sub(r'\b(\d+\.?\d*)\b', r'\1', code) + # Comments + if lang in ("python", "bash"): + code = re.sub(r'(#[^\n]*)', r'\1', code) + elif lang in ("js", "ts", "go", "rust", "css"): + code = re.sub(r'(//[^\n]*)', r'\1', code) + code = re.sub(r'(/\*[\s\S]*?\*/)', r'\1', code) + elif lang == "sql": + code = re.sub(r'(--[^\n]*)', r'\1', code) + return code + + def _escape_html(self, text: str) -> str: + text = text.replace("&", "&") + text = text.replace("<", "<") + text = text.replace(">", ">") + return text + + def _process_tables(self, text: str) -> str: + """Convert GFM pipe tables to HTML tables.""" + lines = text.split('\n') + result: List[str] = [] + i = 0 + while i < len(lines): + # A table starts with a pipe line followed by a separator line (---|---) + if (i + 1 < len(lines) + and '|' in lines[i] + and re.match(r'^\s*\|?[\s\-:|]+\|[\s\-:|]*\|?\s*$', lines[i + 1])): + # Collect header row + header_line = lines[i].strip().strip('|') + sep_line = lines[i + 1] + i += 2 + + # Parse alignments from separator + sep_cells = [c.strip() for c in sep_line.strip().strip('|').split('|')] + aligns: List[str] = [] + for cell in sep_cells: + if cell.startswith(':') and cell.endswith(':'): + aligns.append(' style="text-align:center"') + elif cell.endswith(':'): + aligns.append(' style="text-align:right"') + else: + aligns.append('') + + # Render header + header_cells = [c.strip() for c in header_line.split('|')] + thead = '' + ''.join( + f'{header_cells[j]}' + for j in range(len(header_cells)) + ) + '' + + # Collect body rows + tbody_rows = '' + while i < len(lines) and '|' in lines[i]: + row_line = lines[i].strip().strip('|') + row_cells = [c.strip() for c in row_line.split('|')] + tbody_rows += '' + ''.join( + f'{row_cells[j]}' + for j in range(len(row_cells)) + ) + '' + i += 1 + + result.append(f'
{thead}{tbody_rows}
') + else: + result.append(lines[i]) + i += 1 + return '\n'.join(result) + + def _process_block(self, text: str) -> str: + lines = text.split('\n') + output: List[str] = [] + in_ul = False + in_ol = False + in_blockquote = False + i = 0 + + def close_lists(): + nonlocal in_ul, in_ol, in_blockquote + if in_ul: + output.append('') + in_ul = False + if in_ol: + output.append('') + in_ol = False + if in_blockquote: + output.append('') + in_blockquote = False + + while i < len(lines): + line = lines[i] + + # Horizontal rule + if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', line): + close_lists() + output.append('
') + i += 1 + continue + + # ATX Headers + hm = re.match(r'^(#{1,6})\s+(.*)', line) + if hm: + close_lists() + level = len(hm.group(1)) + content = hm.group(2).strip() + output.append(f'{content}') + i += 1 + continue + + # Blockquote + if line.startswith('> ') or line.startswith('>'): + if not in_blockquote: + close_lists() + output.append('
') + in_blockquote = True + content = re.sub(r'^>\s?', '', line) + output.append(f'

{content}

') + i += 1 + continue + elif in_blockquote: + output.append('
') + in_blockquote = False + + # Unordered list + ulm = re.match(r'^[\*\-\+]\s+(.*)', line) + if ulm: + if not in_ul: + close_lists() + output.append('
    ') + in_ul = True + output.append(f'
  • {ulm.group(1)}
  • ') + i += 1 + continue + + # Ordered list + olm = re.match(r'^\d+\.\s+(.*)', line) + if olm: + if not in_ol: + close_lists() + output.append('
      ') + in_ol = True + output.append(f'
    1. {olm.group(1)}
    2. ') + i += 1 + continue + + # Empty line — close lists, paragraph break + if not line.strip(): + close_lists() + output.append('') + i += 1 + continue + + # Regular paragraph line + if in_ul or in_ol: + close_lists() + + output.append(line) + i += 1 + + close_lists() + + # Wrap consecutive non-tag lines in

      + result = [] + para: List[str] = [] + + def flush_para(): + if para: + result.append('

      ' + '
      '.join(para) + '

      ') + para.clear() + + for line in output: + if not line: + flush_para() + elif line.startswith('<') and not line.startswith(' str: + # Images before links + text = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', + r'\1', text) + # Links + text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', + r'\1', text) + # Bold+italic + text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text) + text = re.sub(r'___(.+?)___', r'\1', text) + # Bold + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + # Italic + text = re.sub(r'\*([^\*\n]+?)\*', r'\1', text) + text = re.sub(r'_([^_\n]+?)_', r'\1', text) + # Strikethrough + text = re.sub(r'~~(.+?)~~', r'\1', text) + return text + + +# --------------------------------------------------------------------------- +# HtmlBuilder — page templates +# --------------------------------------------------------------------------- + +class HtmlBuilder: + """Builds complete HTML pages as strings.""" + + def __init__(self, md: MarkdownRenderer): + self.md = md + + # --- Shared helpers --------------------------------------------------- # + + def _page(self, title: str, body: str, depth: int = 0, + extra_head: str = "", conv_assign_html: str = "") -> str: + root = "../" * depth + return f""" + + + + + {self._esc(title)} — Claude Archive + + {extra_head} + + + {self._topbar(root, conv_assign_html)} +
      + {body} +
      + + +""" + + def _topbar(self, root: str, conv_assign_html: str = "") -> str: + # conv_assign_html is injected right after the nav links, separated by a visual divider + assign_block = ( + f'
      {conv_assign_html}' + if conv_assign_html else "" + ) + return f"""
      + + +
      +
      + + +
      + +
      +
      """ + + def _esc(self, text) -> str: + if text is None: + return "" + return (str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + # Breadcrumb label translations for fixed UI strings + _BC_I18N: Dict[str, Tuple[str, str]] = { + "Главная": ("Главная", "Home"), + "Все чаты": ("Все чаты", "All chats"), + "Проекты": ("Проекты", "Projects"), + "Проект": ("Проект", "Project"), + } + + def _breadcrumbs(self, crumbs: List[Tuple[str, Optional[str]]]) -> str: + parts = [] + for label, href in crumbs: + if label in self._BC_I18N: + ru, en = self._BC_I18N[label] + inner = ( + f'{self._esc(ru)}' + f'{self._esc(en)}' + ) + else: + inner = self._esc(label) + if href: + parts.append(f'{inner}') + else: + parts.append(f'{inner}') + return '' + + def _format_date(self, iso: str, short: bool = False) -> str: + if not iso: + return "—" + try: + if iso.endswith("Z"): + iso = iso[:-1] + "+00:00" + dt = datetime.fromisoformat(iso) + if short: + return dt.strftime("%d.%m.%Y") + return dt.strftime("%d.%m.%Y %H:%M") + except Exception: + return iso + + def _conv_display_name(self, conv: Dict) -> str: + name = conv.get("name", "").strip() + if name: + return name + msgs = conv.get("chat_messages", []) + for msg in msgs: + if msg.get("sender") == "human": + text = msg.get("text", "") + if not text: + for block in msg.get("content", []): + if block.get("type") == "text": + text = block.get("text", "") + break + if text: + clean = re.sub(r"\s+", " ", text).strip() + return clean[:60] + ("..." if len(clean) > 60 else "") + uid = conv.get("uuid", "") + return f"Чат {uid[-8:]}" if uid else "Без названия" + + def _conv_preview(self, conv: Dict, max_len: int = 120) -> str: + msgs = conv.get("chat_messages", []) + for msg in msgs: + if msg.get("sender") == "human": + text = msg.get("text", "") + if not text: + for block in msg.get("content", []): + if block.get("type") == "text": + text = block.get("text", "") + break + if text: + clean = re.sub(r"```[\s\S]*?```", "[код]", text) + clean = re.sub(r"`[^`]+`", "[код]", clean) + clean = re.sub(r"\s+", " ", clean).strip() + return clean[:max_len] + ("..." if len(clean) > max_len else "") + return "" + + # --- Index page ------------------------------------------------------- # + + def build_index(self, loader: DataLoader, mapping: MappingManager, + meta: Dict, new_uuids: set) -> str: + user = loader.user + convs = loader.conversations + projects = loader.projects + + total_msgs = sum(len(c.get("chat_messages", [])) for c in convs) + gen_time = datetime.now().strftime("%d.%m.%Y %H:%M") + + # stats strip + stats_html = f"""
      +
      {len(convs)}чатовchats
      +
      {total_msgs}сообщенийmessages
      +
      {len(projects)}проектовprojects
      +
      {len(mapping.data)}привязаноmapped
      +
      """ + + # user info + user_html = "" + if user: + user_html = f"""
      +
      {self._esc(user.get('full_name','?')[:1])}
      +
      +
      {self._esc(user.get('full_name','Unknown'))}
      +
      {self._esc(user.get('email_address',''))}
      +
      +
      """ + + # projects grid + sorted_projects = sorted(projects.values(), key=lambda p: p.get("updated_at",""), reverse=True) + project_cards = "" + for proj in sorted_projects: + pid = proj.get("uuid","") + pname = self._esc(proj.get("name","Без названия")) + pdesc = self._esc(proj.get("description","") or "") + pdocs = len(proj.get("docs",[])) + pcnt = len(mapping.conversations_for_project(pid)) + pupdated = self._format_date(proj.get("updated_at",""), short=True) + project_cards += f""" +
      {pname}
      + {f'
      {pdesc}
      ' if pdesc else ''} +
      + {pdocs} doc{'s' if pdocs!=1 else ''} + {pcnt} чат{'а' if pcnt in(2,3,4) else 'ов' if pcnt!=1 else ''} + {pupdated} +
      +
      """ + + # recent conversations + recent = sorted(convs, key=lambda c: c.get("updated_at",""), reverse=True)[:15] + recent_rows = "" + for conv in recent: + cid = conv.get("uuid","") + cname = self._esc(self._conv_display_name(conv)) + cupdated = self._format_date(conv.get("updated_at","")) + cmsg = len(conv.get("chat_messages",[])) + cproj_uuid = mapping.get_project(cid) + cproj_name = "" + if cproj_uuid and cproj_uuid in loader.projects: + cproj_name = self._esc(loader.projects[cproj_uuid].get("name","")) + is_new = cid in new_uuids + new_badge = 'new' if is_new else "" + proj_badge = f'{cproj_name}' if cproj_name else "" + recent_rows += f""" + {cname} {new_badge} {proj_badge} + {cmsg} сообщ. · {cupdated} +""" + + # mapping sync panel + sync_panel = """
      +
      + Синхронизация маппингаMapping sync + +
      +

      + Привязки чатов к проектам, сделанные в браузере, хранятся в localStorage. + Нажмите «Экспорт», скачайте файл, положите рядом со скриптом как mapping.json, + затем запустите python declaude.py --remap. + Browser-side project assignments are stored in localStorage. + Click Export, save the file next to the script as mapping.json, + then run python declaude.py --remap. +

      +
      + + +
      +
      """ + + body = f"""
      + {user_html} + {stats_html} +

      Архив сгенерирован:Archive generated: {gen_time}

      + +
      +

      ПроектыProjects

      +
      {project_cards if project_cards else '

      Нет проектов в архиве.No projects in archive.

      '}
      +
      + +
      +

      + Последние чатыRecent chats + Все →All → +

      +
      {recent_rows}
      +
      + +
      +

      Синхронизация маппингаMapping sync

      + {sync_panel} +
      +
      """ + + return self._page("Архив", body, depth=0) + + # --- All conversations page ------------------------------------------- # + + def build_all_conversations(self, loader: DataLoader, mapping: MappingManager, + new_uuids: set) -> str: + convs = loader.conversations + projects = loader.projects + + sorted_convs = sorted(convs, key=lambda c: c.get("updated_at",""), reverse=True) + + # Build project filter options (data-ru/data-en for JS i18n since ' + proj_options += '' + for proj in sorted(projects.values(), key=lambda p: p.get("name","")): + pid = proj.get("uuid","") + pname = self._esc(proj.get("name","")) + proj_options += f'' + + rows_html = "" + for conv in sorted_convs: + cid = conv.get("uuid","") + cname = self._esc(self._conv_display_name(conv)) + cpreview = self._esc(self._conv_preview(conv)) + cupdated = self._format_date(conv.get("updated_at","")) + ccreated = self._format_date(conv.get("created_at",""), short=True) + cmsg = len(conv.get("chat_messages",[])) + cproj_uuid = mapping.get_project(cid) or "" + cproj_name = "" + if cproj_uuid and cproj_uuid in projects: + cproj_name = self._esc(projects[cproj_uuid].get("name","")) + is_new = cid in new_uuids + new_badge = 'new' if is_new else "" + proj_badge = f'{cproj_name}' if cproj_name else "" + + rows_html += f""" +
      + {cname} + {new_badge}{proj_badge} +
      + {f'
      {cpreview}
      ' if cpreview else ''} +
      {cmsg} сообщ.msg · {cupdated} · созданcreated {ccreated}
      +
      """ + + body = f"""
      + {self._breadcrumbs([("Главная","index.html"),("Все чаты",None)])} +

      + Все чаты + All chats + {len(convs)} +

      + +
      + + + + +
      + +
      + {rows_html} +
      +
      """ + + return self._page("Все чаты", body, depth=0, + extra_head='') + + # --- Project page ----------------------------------------------------- # + + def build_project_page(self, project: Dict, loader: DataLoader, + mapping: MappingManager) -> str: + pid = project.get("uuid","") + pname = project.get("name","Без названия") + pdesc = project.get("description","") or "" + pcreated = self._format_date(project.get("created_at","")) + pupdated = self._format_date(project.get("updated_at","")) + pprompt = project.get("prompt_template","") or "" + docs = project.get("docs",[]) + + # Documents section + docs_html = "" + if docs: + for doc in docs: + dname = self._esc(doc.get("filename","Без названия")) + dcreated = self._format_date(doc.get("created_at","")) + dcontent = doc.get("content","") or "" + doc_rendered = self.md.render(dcontent) if dcontent else "

      Нет содержимого.

      " + docs_html += f"""
      + + 📄 + {dname} + {dcreated} + +
      {doc_rendered}
      +
      """ + else: + docs_html = '

      Нет документов в этом проекте.

      ' + + # Prompt template + prompt_html = "" + if pprompt: + prompt_rendered = self.md.render(pprompt) + prompt_html = f"""
      +

      Системный промпт

      +
      {prompt_rendered}
      +
      """ + + # Conversations + conv_uuids = mapping.conversations_for_project(pid) + conv_map = {c.get("uuid",""): c for c in loader.conversations} + proj_convs = [conv_map[u] for u in conv_uuids if u in conv_map] + proj_convs.sort(key=lambda c: c.get("updated_at",""), reverse=True) + + conv_rows = "" + for conv in proj_convs: + cid = conv.get("uuid","") + cname = self._esc(self._conv_display_name(conv)) + cpreview = self._esc(self._conv_preview(conv)) + cupdated = self._format_date(conv.get("updated_at","")) + cmsg = len(conv.get("chat_messages",[])) + conv_rows += f""" + {cname} + {cmsg} сообщ. · {cupdated} + +{f'
      {cpreview}
      ' if cpreview else ''}""" + + convs_section = f"""
      +

      Привязанные чаты {len(proj_convs)}

      + {f'
      {conv_rows}
      ' if proj_convs else '

      Нет привязанных чатов. Откройте чат и нажмите «Привязать к проекту».

      '} +
      """ + + body = f"""
      + {self._breadcrumbs([("Главная","../../index.html"),("Проекты","../../index.html#projects"),(pname,None)])} +

      {self._esc(pname)}

      + +
      + {f'

      {self._esc(pdesc)}

      ' if pdesc else ''} +
      + Создан{pcreated} + Обновлён{pupdated} + Документов{len(docs)} + Чатов{len(proj_convs)} +
      +
      + + {prompt_html} + +
      +

      Документы {len(docs)}

      +
      {docs_html}
      +
      + + {convs_section} +
      """ + + return self._page(pname, body, depth=2) + + # --- Conversation page ------------------------------------------------ # + + def build_conversation_page(self, conv: Dict, loader: DataLoader, + mapping: MappingManager) -> str: + cid = conv.get("uuid","") + cname = self._conv_display_name(conv) + ccreated = self._format_date(conv.get("created_at","")) + cupdated = self._format_date(conv.get("updated_at","")) + messages = conv.get("chat_messages",[]) + + # Sort messages by created_at + try: + messages = sorted(messages, key=lambda m: m.get("created_at","")) + except Exception: + pass + + # Current project assignment + cproj_uuid = mapping.get_project(cid) or "" + cproj_name = "" + if cproj_uuid and cproj_uuid in loader.projects: + cproj_name = loader.projects[cproj_uuid].get("name","") + + # Project assignment widget — goes into topbar + # Note: ' + for proj in sorted(loader.projects.values(), key=lambda p: p.get("name","")): + pid2 = proj.get("uuid","") + pn = self._esc(proj.get("name","")) + selected = 'selected' if pid2 == cproj_uuid else '' + proj_options_html += f'' + + open_proj_link = ( + f'' + f'Открыть →' + f'Open →' + f'' + if cproj_uuid else + f'' + ) + + conv_assign_html = f"""
      + + Проект: + Project: + + + {open_proj_link} +
      """ + + # FAB scroll buttons + scroll_fabs = """
      + + +
      """ + + # Messages + msgs_html = "" + for i, msg in enumerate(messages): + sender = msg.get("sender","unknown") + ts = self._format_date(msg.get("created_at","")) + text = msg.get("text","") + if not text: + parts = [] + for block in msg.get("content",[]): + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text","")) + text = "\n".join(parts) + + attachments = msg.get("attachments",[]) + files = msg.get("files",[]) + + rendered = self.md.render(text) if text else ( + '

      ' + '[нет текста]' + '[no text]' + '

      ' + ) + + # Attachments + attach_html = "" + for att in attachments: + fname = self._esc(att.get("file_name","файл")) + fsize = att.get("file_size",0) + ftype = self._esc(att.get("file_type","") or "") + extracted = att.get("extracted_content","") or "" + size_str = f"{fsize:,}".replace(",", " ") + " б" if fsize else "" + if extracted: + ext_rendered = self.md.render(extracted) + attach_html += f"""
      + + 📎 + {fname} + {f'{ftype} · {size_str}' if ftype or size_str else ''} + +
      {ext_rendered}
      +
      """ + else: + attach_html += f"""
      + 📎 + {fname} + {f'{ftype} · {size_str}' if ftype or size_str else ''} +
      """ + + for fobj in files: + fname = self._esc(fobj.get("file_name","файл")) + attach_html += f'
      📁 {fname}
      ' + + is_human = sender == "human" + role_cls = "msg-human" if is_human else "msg-claude" + avatar = f"""
      + {'' if is_human else ''} +
      """ + sender_label_html = ( + 'ВыYou' + if is_human else "Claude" + ) + + msgs_html += f"""
      +
      + {avatar} + {sender_label_html} + {ts} +
      +
      +
      {rendered}
      + {f'
      {attach_html}
      ' if attach_html else ''} +
      +
      """ + + body = f"""
      + {self._breadcrumbs([("Главная","../index.html"),("Все чаты","../all_conversations.html"),(cname,None)])} +

      {self._esc(cname)}

      +
      + Создан:Created: {ccreated} + Обновлён:Updated: {cupdated} + {len(messages)} сообщенийmessages + ID: {cid} +
      + +
      + {msgs_html} +
      + {scroll_fabs} +
      """ + + return self._page( + cname, body, depth=1, + extra_head=f'', + conv_assign_html=conv_assign_html + ) + + +# --------------------------------------------------------------------------- +# CSS +# --------------------------------------------------------------------------- + +CSS = r""" +/* ===== Reset & base ===== */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #1a1a1a; + --bg2: #242424; + --bg3: #2e2e2e; + --border: #383838; + --text: #e8e6e0; + --text2: #a0a0a0; + --text3: #6b6b6b; + --accent: #d97706; + --accent-hover: #b45309; + --human-bg: #1e2d40; + --human-border: #2a4a6e; + --claude-bg: #242424; + --claude-border: #383838; + --code-bg: #1a1a1a; + --code-header: #2a2a2a; + --link: #60a5fa; + --badge-new: #16a34a; + --badge-proj: #7c3aed; + --shadow: 0 1px 3px rgba(0,0,0,0.4); + --radius: 10px; + --topbar-h: 52px; +} + +[data-theme="light"] { + --bg: #f7f5f2; + --bg2: #ffffff; + --bg3: #f0ede8; + --border: #e0dbd4; + --text: #1a1a1a; + --text2: #555; + --text3: #999; + --accent: #d97706; + --accent-hover: #b45309; + --human-bg: #eff6ff; + --human-border: #bfdbfe; + --claude-bg: #ffffff; + --claude-border: #e5e7eb; + --code-bg: #f8f8f8; + --code-header: #ececec; + --link: #2563eb; + --badge-new: #15803d; + --badge-proj: #6d28d9; + --shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +html { font-size: 16px; scroll-behavior: smooth; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.65; + min-height: 100vh; +} + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +.muted { color: var(--text2); font-size: 0.875rem; } + +/* ===== Topbar ===== */ +.topbar { + position: sticky; top: 0; z-index: 100; + height: var(--topbar-h); + background: var(--bg2); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 1.5rem; + padding: 0 1.5rem; +} +.topbar-logo { + display: flex; align-items: center; gap: 0.5rem; + color: var(--text); font-weight: 600; font-size: 1rem; + text-decoration: none; white-space: nowrap; +} +.topbar-logo svg { color: var(--accent); } +.topbar-nav { display: flex; align-items: center; gap: 1rem; } +.topbar-nav a { color: var(--text2); font-size: 0.9rem; } +.topbar-nav a:hover { color: var(--text); text-decoration: none; } +.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } +.icon-btn { + background: none; border: none; cursor: pointer; + color: var(--text2); padding: 6px; border-radius: 6px; + display: flex; align-items: center; +} +.icon-btn:hover { background: var(--bg3); color: var(--text); } + +/* ===== Layout ===== */ +.page-wrapper { max-width: 1040px; margin: 0 auto; padding: 2rem 1.25rem 4rem; } +.main-content { display: flex; flex-direction: column; gap: 2.5rem; } + +/* ===== Breadcrumbs ===== */ +.breadcrumbs { + display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; + font-size: 0.85rem; color: var(--text2); +} +.breadcrumbs a { color: var(--text2); } +.breadcrumbs a:hover { color: var(--text); } +.bc-sep { color: var(--text3); } + +/* ===== Section ===== */ +.section-title { + font-size: 1.1rem; font-weight: 600; color: var(--text); + margin-bottom: 1rem; + display: flex; align-items: center; gap: 0.75rem; +} +.section-link { font-size: 0.85rem; font-weight: 400; color: var(--accent); margin-left: auto; } +.section-link:hover { color: var(--accent-hover); } + +/* ===== Stats strip ===== */ +.stats-strip { + display: flex; gap: 1rem; flex-wrap: wrap; + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1rem 1.5rem; +} +.stat-item { display: flex; flex-direction: column; align-items: center; flex: 1; min-width: 80px; } +.stat-num { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; } +.stat-lbl { font-size: 0.78rem; color: var(--text2); margin-top: 0.25rem; } + +/* ===== Cards ===== */ +.card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + box-shadow: var(--shadow); +} + +/* ===== User card ===== */ +.user-card { display: flex; align-items: center; gap: 1rem; } +.user-avatar { + width: 44px; height: 44px; border-radius: 50%; + background: var(--accent); color: white; + font-weight: 700; font-size: 1.1rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.user-name { font-weight: 600; } +.user-email { font-size: 0.85rem; color: var(--text2); } + +/* ===== Projects grid ===== */ +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} +.project-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.1rem 1.25rem; + text-decoration: none; color: var(--text); + transition: border-color 0.15s, box-shadow 0.15s; + display: flex; flex-direction: column; gap: 0.4rem; +} +.project-card:hover { + border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); + text-decoration: none; +} +.pc-name { font-weight: 600; font-size: 0.95rem; } +.pc-desc { font-size: 0.82rem; color: var(--text2); line-height: 1.4; } +.pc-meta { font-size: 0.78rem; color: var(--text3); display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; } + +/* ===== Conversation list (compact rows) ===== */ +.conv-list { display: flex; flex-direction: column; gap: 0.375rem; } +.conv-row { + display: flex; justify-content: space-between; align-items: center; + padding: 0.6rem 0.875rem; + background: var(--bg2); border: 1px solid var(--border); + border-radius: 8px; text-decoration: none; color: var(--text); + transition: border-color 0.12s; + gap: 1rem; +} +.conv-row:hover { border-color: var(--accent); text-decoration: none; } +.conv-row-name { font-size: 0.9rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conv-row-meta { font-size: 0.78rem; color: var(--text2); white-space: nowrap; flex-shrink: 0; } + +/* ===== Badges ===== */ +.badge { + display: inline-block; padding: 1px 7px; border-radius: 20px; + font-size: 0.7rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.03em; +} +.badge-new { background: var(--badge-new); color: #fff; } +.badge-proj { background: var(--badge-proj); color: #fff; } +.count-badge { + font-size: 0.78rem; color: var(--text2); font-weight: 400; + background: var(--bg3); border-radius: 20px; padding: 1px 8px; +} + +/* ===== All conversations grid ===== */ +.filters-bar { + display: flex; flex-wrap: wrap; gap: 0.625rem; align-items: center; +} +.search-input, .select-input { + background: var(--bg2); border: 1px solid var(--border); + color: var(--text); border-radius: 8px; padding: 0.5rem 0.75rem; + font-size: 0.875rem; outline: none; +} +.search-input { flex: 1; min-width: 200px; } +.search-input:focus, .select-input:focus { border-color: var(--accent); } +.filter-count { font-size: 0.82rem; color: var(--text2); margin-left: auto; } + +.conv-grid { display: flex; flex-direction: column; gap: 0.5rem; } +.conv-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 0.875rem 1rem; + text-decoration: none; color: var(--text); + transition: border-color 0.12s; + display: flex; flex-direction: column; gap: 0.3rem; +} +.conv-card:hover { border-color: var(--accent); text-decoration: none; } +.cc-top { display: flex; align-items: center; gap: 0.5rem; } +.cc-name { font-size: 0.9rem; font-weight: 500; flex: 1; } +.cc-badges { display: flex; gap: 0.3rem; flex-shrink: 0; } +.cc-preview { font-size: 0.82rem; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cc-meta { font-size: 0.75rem; color: var(--text3); } + +/* ===== Sync panel ===== */ +.sync-panel { display: flex; flex-direction: column; gap: 0.75rem; } +.sync-panel-header { display: flex; align-items: center; justify-content: space-between; } +.sync-status { font-size: 0.82rem; color: var(--text2); } +.sync-desc { font-size: 0.85rem; color: var(--text2); line-height: 1.5; } +.sync-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; } + +/* ===== Buttons ===== */ +.btn { + padding: 0.45rem 1rem; border-radius: 7px; font-size: 0.85rem; + border: 1px solid var(--border); background: var(--bg3); + color: var(--text); cursor: pointer; transition: background 0.12s; +} +.btn:hover { background: var(--border); } +.btn-primary { background: var(--accent); border-color: var(--accent); color: white; } +.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } + +/* ===== Project page ===== */ +.meta-card { } +.project-desc { color: var(--text2); margin-bottom: 0.75rem; font-size: 0.95rem; } +.meta-grid { + display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1.25rem; + font-size: 0.875rem; +} +.meta-lbl { color: var(--text2); font-weight: 500; } + +.docs-list { display: flex; flex-direction: column; gap: 0.5rem; } +.doc-block { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; +} +.doc-summary { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.75rem 1rem; cursor: pointer; list-style: none; + user-select: none; +} +.doc-summary:hover { background: var(--bg3); } +.doc-name { font-weight: 500; font-size: 0.9rem; flex: 1; } +.doc-date { font-size: 0.78rem; } +.doc-content { padding: 1rem 1.25rem; border-top: 1px solid var(--border); } + +.prompt-content { + background: var(--bg3); border-left: 3px solid var(--accent); + padding: 0.875rem 1rem; border-radius: 0 6px 6px 0; + font-size: 0.9rem; +} + +/* ===== Conversation page ===== */ +.conv-page { gap: 1.25rem; } +.conv-header { display: flex; flex-direction: column; gap: 0.75rem; } +.conv-title { font-size: 1.3rem; font-weight: 700; line-height: 1.3; } +.conv-meta-bar { + display: flex; flex-wrap: wrap; gap: 1rem; + font-size: 0.8rem; color: var(--text2); + padding: 0.6rem 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); +} +.conv-uuid { font-family: monospace; font-size: 0.72rem; } + + +/* Messages */ +.messages-container { display: flex; flex-direction: column; gap: 1.25rem; } + +.message { display: flex; flex-direction: column; gap: 0.5rem; } +.msg-meta { display: flex; align-items: center; gap: 0.5rem; } +.msg-avatar { + width: 28px; height: 28px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.avatar-human { background: #2a4a6e; color: #93c5fd; } +.avatar-claude { background: #3a2a1a; color: var(--accent); } +.msg-sender { font-weight: 600; font-size: 0.875rem; } +.msg-ts { font-size: 0.75rem; color: var(--text3); } + +.msg-body { + padding: 0.875rem 1rem; + border-radius: 0 var(--radius) var(--radius) var(--radius); + border: 1px solid var(--border); +} +.msg-human .msg-body { + background: var(--human-bg); border-color: var(--human-border); + margin-left: 2rem; +} +.msg-claude .msg-body { + background: var(--claude-bg); border-color: var(--claude-border); + margin-left: 2rem; +} + +/* Message content prose */ +.msg-content { font-size: 0.92rem; line-height: 1.7; } +.msg-content p { margin-bottom: 0.6rem; } +.msg-content p:last-child { margin-bottom: 0; } +.msg-content h1,.msg-content h2,.msg-content h3, +.msg-content h4,.msg-content h5,.msg-content h6 { + margin: 1rem 0 0.4rem; font-weight: 600; +} +.msg-content ul, .msg-content ol { margin: 0.4rem 0 0.6rem 1.4rem; } +.msg-content li { margin-bottom: 0.2rem; } +.msg-content blockquote { + border-left: 3px solid var(--accent); + margin: 0.6rem 0; padding: 0.4rem 0.875rem; + background: var(--bg3); border-radius: 0 6px 6px 0; + color: var(--text2); +} +.msg-content a { color: var(--link); } +.msg-content hr { border: none; border-top: 1px solid var(--border); margin: 0.75rem 0; } +.msg-content strong { font-weight: 700; } +.msg-content em { font-style: italic; } +.msg-content del { text-decoration: line-through; color: var(--text2); } + +/* Code */ +.inline-code { + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace; + font-size: 0.82em; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + color: #f59e0b; +} +.code-block { + background: var(--code-bg); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; margin: 0.75rem 0; +} +.code-header { + display: flex; justify-content: space-between; align-items: center; + padding: 0.4rem 0.875rem; + background: var(--code-header); + border-bottom: 1px solid var(--border); +} +.code-lang { font-size: 0.75rem; color: var(--text2); font-family: monospace; } +.copy-btn { + font-size: 0.75rem; color: var(--text2); background: none; + border: 1px solid var(--border); border-radius: 5px; + padding: 2px 8px; cursor: pointer; +} +.copy-btn:hover { background: var(--border); color: var(--text); } +.copy-btn.copied { color: #16a34a; border-color: #16a34a; } +.code-block pre { + overflow-x: auto; padding: 0.875rem 1rem; + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.82rem; line-height: 1.6; +} +.code-block code { background: none; border: none; padding: 0; font-size: inherit; color: var(--text); } + +/* Syntax highlight */ +.hl-keyword { color: #c084fc; font-weight: 500; } +.hl-string { color: #86efac; } +.hl-number { color: #fb923c; } +.hl-comment { color: #6b7280; font-style: italic; } + +/* Attachments */ +.msg-attachments { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; } +.attachment { + background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; +} +.att-summary { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.5rem 0.75rem; cursor: pointer; list-style: none; +} +.att-summary:hover { background: var(--border); } +.att-badge { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; } +.att-name { font-size: 0.82rem; font-weight: 500; } +.att-meta { font-size: 0.75rem; color: var(--text3); } +.att-content { padding: 0.75rem 1rem; border-top: 1px solid var(--border); font-size: 0.85rem; } + +/* ===== Tables ===== */ +.table-wrap { overflow-x: auto; margin: 0.75rem 0; border-radius: var(--radius); border: 1px solid var(--border); } +.table-wrap table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.table-wrap th { + background: var(--bg3); color: var(--text); + font-weight: 600; text-align: left; + padding: 0.55rem 0.875rem; + border-bottom: 2px solid var(--border); + white-space: nowrap; +} +.table-wrap td { + padding: 0.5rem 0.875rem; + border-bottom: 1px solid var(--border); + color: var(--text); + vertical-align: top; +} +.table-wrap tr:last-child td { border-bottom: none; } +.table-wrap tr:hover td { background: var(--bg3); } + +/* ===== Scroll FAB buttons ===== */ +.scroll-fabs { + position: fixed; + right: 1.5rem; + bottom: 2rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 200; +} +.scroll-fab { + width: 40px; height: 40px; + border-radius: 50%; + background: var(--bg2); + border: 1px solid var(--border); + color: var(--text2); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.35); + transition: background 0.15s, color 0.15s, opacity 0.2s; + opacity: 0.75; +} +.scroll-fab:hover { background: var(--accent); border-color: var(--accent); color: #fff; opacity: 1; } +.scroll-fab.hidden { opacity: 0; pointer-events: none; } + +/* ===== Topbar divider (between nav links and assign widget) ===== */ +.topbar-divider { + width: 1px; height: 18px; + background: var(--border); + margin: 0 0.5rem; + flex-shrink: 0; +} + +/* ===== Topbar assign widget (sits inside topbar-nav) ===== */ +.topbar-assign { + display: flex; align-items: center; gap: 0.5rem; +} +.topbar-assign-label { font-size: 0.8rem; color: var(--text2); white-space: nowrap; } +.topbar-assign .select-input { font-size: 0.8rem; padding: 0.3rem 0.6rem; max-width: 200px; } +.topbar-assign-link { font-size: 0.8rem; color: var(--accent); white-space: nowrap; } +.topbar-assign-link:hover { color: var(--accent-hover); } + +/* ===== Language toggle ===== */ +.lang-toggle { + display: flex; gap: 0; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; +} +.lang-btn { + background: none; border: none; cursor: pointer; + color: var(--text2); font-size: 0.78rem; font-weight: 600; + padding: 4px 9px; transition: background 0.12s, color 0.12s; +} +.lang-btn.active { background: var(--accent); color: #fff; } +.lang-btn:not(.active):hover { background: var(--bg3); color: var(--text); } + +/* ===== i18n hidden ===== */ +[data-lang] { display: none; } +[data-lang].lang-visible { display: inline; } +[data-lang-block] { display: none; } +[data-lang-block].lang-visible { display: block; } + +/* ===== Gen note ===== */ +.gen-note { font-size: 0.78rem; color: var(--text3); } + +/* ===== Responsive ===== */ +@media (max-width: 640px) { + .page-wrapper { padding: 1rem 0.875rem 3rem; } + .topbar { padding: 0 1rem; gap: 0.75rem; } + .topbar-nav { display: none; } + .topbar-assign .select-input { max-width: 130px; } + .topbar-divider { display: none; } + .stats-strip { gap: 0.5rem; padding: 0.875rem 1rem; } + .stat-num { font-size: 1.4rem; } + .projects-grid { grid-template-columns: 1fr; } + .conv-row { flex-direction: column; align-items: flex-start; gap: 0.2rem; } + .conv-row-meta { align-self: flex-end; } + .msg-human .msg-body, .msg-claude .msg-body { margin-left: 0; } + .scroll-fabs { right: 0.75rem; bottom: 1rem; } +} +""" + + +# --------------------------------------------------------------------------- +# JavaScript +# --------------------------------------------------------------------------- + +JS = r""" +// ===== Theme ===== +(function() { + var saved = localStorage.getItem('claude-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', saved); + updateThemeIcons(saved); +})(); + +function toggleTheme() { + var current = document.documentElement.getAttribute('data-theme'); + var next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('claude-theme', next); + updateThemeIcons(next); +} + +function updateThemeIcons(theme) { + var moon = document.querySelector('.icon-moon'); + var sun = document.querySelector('.icon-sun'); + if (!moon || !sun) return; + if (theme === 'dark') { moon.style.display = ''; sun.style.display = 'none'; } + else { moon.style.display = 'none'; sun.style.display = ''; } +} + +// ===== Language (i18n) ===== +(function() { + var saved = localStorage.getItem('claude-lang') || 'ru'; + applyLang(saved, false); +})(); + +function setLang(lang) { + localStorage.setItem('claude-lang', lang); + applyLang(lang, true); +} + +function applyLang(lang, animate) { + // Show/hide inline spans + document.querySelectorAll('[data-lang]').forEach(function(el) { + if (el.getAttribute('data-lang') === lang) { + el.classList.add('lang-visible'); + } else { + el.classList.remove('lang-visible'); + } + }); + // Show/hide block elements + document.querySelectorAll('[data-lang-block]').forEach(function(el) { + if (el.getAttribute('data-lang-block') === lang) { + el.classList.add('lang-visible'); + } else { + el.classList.remove('lang-visible'); + } + }); + // Translate option elements that have data-ru / data-en attributes + document.querySelectorAll('option[data-ru]').forEach(function(el) { + var t = el.getAttribute('data-' + lang); + if (t) el.textContent = t; + }); + // Translate search input placeholder + document.querySelectorAll('input[data-placeholder-ru]').forEach(function(el) { + var t = el.getAttribute('data-placeholder-' + lang); + if (t) el.placeholder = t; + }); + // Toggle button states + var btnRu = document.getElementById('langRu'); + var btnEn = document.getElementById('langEn'); + if (btnRu) btnRu.classList.toggle('active', lang === 'ru'); + if (btnEn) btnEn.classList.toggle('active', lang === 'en'); + // Update sync status text if on index + updateSyncStatus(); + // Update filter count label + var label = document.getElementById('countLabel'); + if (label && label.textContent) filterConvs(); +} + +// ===== Copy code ===== +function copyCode(btn) { + var pre = btn.closest('.code-block').querySelector('pre'); + var text = pre ? pre.innerText : ''; + navigator.clipboard.writeText(text).then(function() { + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500); + }).catch(function() { + btn.textContent = 'Error'; + setTimeout(function() { btn.textContent = 'Copy'; }, 1500); + }); +} + +// ===== Scroll FABs ===== +function scrollToTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function scrollToBottom() { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); +} + +function updateScrollFabs() { + var fabTop = document.getElementById('fabTop'); + var fabBottom = document.getElementById('fabBottom'); + if (!fabTop || !fabBottom) return; + var scrolled = window.scrollY; + var maxScroll = document.body.scrollHeight - window.innerHeight; + // Show "top" button after scrolling down 200px + fabTop.classList.toggle('hidden', scrolled < 200); + // Hide "bottom" button when within 100px of the bottom + fabBottom.classList.toggle('hidden', maxScroll > 0 && scrolled >= maxScroll - 100); +} + +// ===== Mapping (localStorage) ===== +var MAPPING_KEY = 'claude-mapping'; + +function loadBrowserMapping() { + try { return JSON.parse(localStorage.getItem(MAPPING_KEY) || '{}'); } + catch(e) { return {}; } +} + +function saveBrowserMapping(m) { + localStorage.setItem(MAPPING_KEY, JSON.stringify(m)); +} + +function assignProject(convUuid, projUuid) { + var m = loadBrowserMapping(); + if (projUuid) { + m[convUuid] = projUuid; + } else { + delete m[convUuid]; + } + saveBrowserMapping(m); + // Update the "Open project" link in topbar + var link = document.getElementById('assignProjLink'); + if (link) { + if (projUuid) { + link.href = '../projects/' + projUuid + '/index.html'; + link.style.display = ''; + } else { + link.style.display = 'none'; + } + } +} + +function exportMapping() { + var m = loadBrowserMapping(); + var json = JSON.stringify(m, null, 2); + var blob = new Blob([json], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'mapping.json'; + a.click(); + URL.revokeObjectURL(a.href); +} + +function clearBrowserMapping() { + var lang = localStorage.getItem('claude-lang') || 'ru'; + var msg = lang === 'en' + ? 'Reset all browser mapping? (mapping.json on disk will not change)' + : 'Сбросить весь браузерный маппинг? (mapping.json на диске не изменится)'; + if (confirm(msg)) { + localStorage.removeItem(MAPPING_KEY); + updateSyncStatus(); + } +} + +function updateSyncStatus() { + var el = document.getElementById('syncStatus'); + if (!el) return; + var m = loadBrowserMapping(); + var cnt = Object.keys(m).length; + var lang = localStorage.getItem('claude-lang') || 'ru'; + if (lang === 'en') { + el.textContent = cnt > 0 ? cnt + ' mappings in browser (not saved)' : 'No unsaved mappings'; + } else { + el.textContent = cnt > 0 ? cnt + ' привязок в браузере (не сохранено)' : 'Нет несохранённых привязок'; + } +} + +// ===== Filter & search (all_conversations page) ===== +function filterConvs() { + var q = (document.getElementById('searchInput') || { value: '' }).value.toLowerCase(); + var proj = (document.getElementById('projFilter') || { value: '' }).value; + var sort = (document.getElementById('sortSelect') || { value: 'updated' }).value; + + var cards = Array.from(document.querySelectorAll('.conv-card')); + var visible = []; + + cards.forEach(function(card) { + var name = (card.dataset.name || ''); + var preview = (card.dataset.preview || ''); + var cardProj = (card.dataset.proj || ''); + var matchQ = !q || name.includes(q) || preview.includes(q); + var matchP = !proj || (proj === '__none__' ? !cardProj : cardProj === proj); + var show = matchQ && matchP; + card.style.display = show ? '' : 'none'; + if (show) visible.push(card); + }); + + // Sort + var grid = document.getElementById('convGrid'); + if (grid) { + visible.sort(function(a, b) { + if (sort === 'msgs') return parseInt(b.dataset.msgs || 0) - parseInt(a.dataset.msgs || 0); + if (sort === 'created') return (b.dataset.created || '').localeCompare(a.dataset.created || ''); + return (b.dataset.updated || '').localeCompare(a.dataset.updated || ''); + }); + visible.forEach(function(c) { grid.appendChild(c); }); + } + + var lang = localStorage.getItem('claude-lang') || 'ru'; + var label = document.getElementById('countLabel'); + if (label) { + label.textContent = lang === 'en' + ? visible.length + ' of ' + cards.length + : visible.length + ' из ' + cards.length; + } +} + +// ===== Init ===== +document.addEventListener('DOMContentLoaded', function() { + // Re-apply theme icons (DOM now ready) + var savedTheme = localStorage.getItem('claude-theme') || 'dark'; + updateThemeIcons(savedTheme); + + // Apply language + var savedLang = localStorage.getItem('claude-lang') || 'ru'; + applyLang(savedLang, false); + + if (window.PAGE_TYPE === 'all_convs') { + filterConvs(); + } + if (document.getElementById('syncPanel')) { + updateSyncStatus(); + } + if (window.PAGE_TYPE === 'conversation') { + window.addEventListener('scroll', updateScrollFabs, { passive: true }); + updateScrollFabs(); + } +}); +""" + + +# --------------------------------------------------------------------------- +# SiteBuilder +# --------------------------------------------------------------------------- + +class SiteBuilder: + """Orchestrates generating the full static site.""" + + def __init__(self, output_dir: Path, loader: DataLoader, mapping: MappingManager): + self.out = output_dir + self.meta_file = output_dir / "_meta.json" + self.loader = loader + self.mapping = mapping + self.md = MarkdownRenderer() + self.builder = HtmlBuilder(self.md) + + def _write(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + def build_assets(self) -> None: + assets = self.out / "assets" + assets.mkdir(parents=True, exist_ok=True) + (assets / "style.css").write_text(CSS, encoding="utf-8") + (assets / "app.js").write_text(JS, encoding="utf-8") + + def build_all(self, new_uuids: Optional[set] = None) -> None: + new_uuids = new_uuids or set() + print(" Building assets...") + self.build_assets() + + print(" Building index...") + meta = self._load_meta() + html = self.builder.build_index(self.loader, self.mapping, meta, new_uuids) + self._write(self.out / "index.html", html) + + print(" Building all_conversations...") + html = self.builder.build_all_conversations(self.loader, self.mapping, new_uuids) + self._write(self.out / "all_conversations.html", html) + + print(f" Building {len(self.loader.projects)} project pages...") + for proj in self.loader.projects.values(): + pid = proj.get("uuid","") + html = self.builder.build_project_page(proj, self.loader, self.mapping) + self._write(self.out / "projects" / pid / "index.html", html) + + print(f" Building {len(self.loader.conversations)} conversation pages...") + for i, conv in enumerate(self.loader.conversations, 1): + cid = conv.get("uuid","") + html = self.builder.build_conversation_page(conv, self.loader, self.mapping) + self._write(self.out / "conversations" / f"{cid}.html", html) + if i % 100 == 0: + print(f" {i}/{len(self.loader.conversations)}") + + self._save_meta() + print(f" Done. Archive at: {self.out / 'index.html'}") + + def build_remap(self) -> None: + """Regenerate only index, all_conversations, and project pages (fast after mapping change).""" + print(" Rebuilding index and project pages...") + meta = self._load_meta() + new_uuids: set = set() + + html = self.builder.build_index(self.loader, self.mapping, meta, new_uuids) + self._write(self.out / "index.html", html) + + html = self.builder.build_all_conversations(self.loader, self.mapping, new_uuids) + self._write(self.out / "all_conversations.html", html) + + for proj in self.loader.projects.values(): + pid = proj.get("uuid","") + html = self.builder.build_project_page(proj, self.loader, self.mapping) + self._write(self.out / "projects" / pid / "index.html", html) + + print(" Remap complete.") + + def build_update(self, new_loader: DataLoader) -> None: + """Merge new backup data, regenerate changed pages only.""" + existing_uuids = {c.get("uuid","") for c in self.loader.conversations} + new_uuids = set() + changed_uuids = set() + + for conv in new_loader.conversations: + cid = conv.get("uuid","") + if cid not in existing_uuids: + new_uuids.add(cid) + else: + # Find old version and compare updated_at + old = next((c for c in self.loader.conversations if c.get("uuid") == cid), None) + if old and old.get("updated_at","") != conv.get("updated_at",""): + changed_uuids.add(cid) + + print(f" New conversations: {len(new_uuids)}, Changed: {len(changed_uuids)}") + + # Replace loader data with new + self.loader.conversations = new_loader.conversations + self.loader.projects = new_loader.projects + self.loader.user = new_loader.user + + # Regenerate only new/changed conversation pages + conv_map = {c.get("uuid",""): c for c in self.loader.conversations} + to_regen = new_uuids | changed_uuids + for cid in to_regen: + if cid in conv_map: + conv = conv_map[cid] + html = self.builder.build_conversation_page(conv, self.loader, self.mapping) + self._write(self.out / "conversations" / f"{cid}.html", html) + + # Rebuild index pages + meta = self._load_meta() + html = self.builder.build_index(self.loader, self.mapping, meta, new_uuids) + self._write(self.out / "index.html", html) + + html = self.builder.build_all_conversations(self.loader, self.mapping, new_uuids) + self._write(self.out / "all_conversations.html", html) + + # Rebuild affected project pages + affected_projects: set = set() + for cid in to_regen: + pid = self.mapping.get_project(cid) + if pid: + affected_projects.add(pid) + for pid in affected_projects: + if pid in self.loader.projects: + proj = self.loader.projects[pid] + html = self.builder.build_project_page(proj, self.loader, self.mapping) + self._write(self.out / "projects" / pid / "index.html", html) + + # Rebuild all project pages (new projects may have appeared) + for proj in self.loader.projects.values(): + pid = proj.get("uuid","") + if pid not in affected_projects: + html = self.builder.build_project_page(proj, self.loader, self.mapping) + self._write(self.out / "projects" / pid / "index.html", html) + + self.build_assets() + self._save_meta() + print(f" Update complete. {len(new_uuids)} new, {len(changed_uuids)} changed.") + + def _load_meta(self) -> Dict: + if self.meta_file.exists(): + try: + with open(self.meta_file, encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + def _save_meta(self) -> None: + meta = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "conversation_uuids": [c.get("uuid","") for c in self.loader.conversations], + "project_uuids": list(self.loader.projects.keys()), + } + self.meta_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.meta_file, "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + + +# --------------------------------------------------------------------------- +# Zip extraction helper +# --------------------------------------------------------------------------- + +def extract_zip(zip_path: Path, target_dir: Path) -> Path: + """Extract zip archive to target_dir, return the root directory inside.""" + if target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True) + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(target_dir) + # If zip has a single root folder, use that + children = list(target_dir.iterdir()) + if len(children) == 1 and children[0].is_dir(): + return children[0] + return target_dir + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="declaude — Claude backup → static HTML archive", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Commands: + --build Generate full archive from backup directory + --update path/to/new.zip Merge new backup zip, regenerate changed pages only + --remap Rebuild index + project pages after editing mapping.json + --map CONV_UUID PROJ_UUID Assign a conversation to a project in mapping.json + +Source options: + --source PATH Path to backup directory (default: ./claude-backup-v1) + --output PATH Output directory (default: ./claude_archive) + --mapping PATH mapping.json path (default: ./mapping.json) + +Examples: + python declaude.py --build + python declaude.py --build --source ./my-backup --output ./my-archive + python declaude.py --update new_export.zip + python declaude.py --remap + python declaude.py --map abc123 proj456 + """ + ) + parser.add_argument("--build", action="store_true", help="Full build from source") + parser.add_argument("--update", metavar="ZIP", help="Update from new zip archive") + parser.add_argument("--remap", action="store_true", help="Rebuild index/project pages after mapping change") + parser.add_argument("--map", nargs=2, metavar=("CONV_UUID","PROJ_UUID"), + help="Assign conversation to project in mapping.json") + parser.add_argument("--source", default="claude-backup-v1", metavar="PATH", + help="Source backup directory (default: claude-backup-v1)") + parser.add_argument("--output", default=str(OUTPUT_DIR), metavar="PATH", + help=f"Output directory (default: {OUTPUT_DIR})") + parser.add_argument("--mapping", default=str(MAPPING_FILE), metavar="PATH", + help=f"Mapping file path (default: {MAPPING_FILE})") + + args = parser.parse_args() + + out_dir = Path(args.output) + mapping_path = Path(args.mapping) + source_dir = Path(args.source) + + # Load mapping (always) + mapping = MappingManager(mapping_path) + mapping.load() + + # --map: just add one entry and exit + if args.map: + conv_uuid, proj_uuid = args.map + mapping.assign(conv_uuid, proj_uuid) + mapping.save() + print(f"Mapped conversation {conv_uuid} -> project {proj_uuid}") + print(f"Saved to {mapping_path}. Run --remap to rebuild project pages.") + return + + # --remap: load data, rebuild index + project pages + if args.remap: + print(f"Loading data from {source_dir}...") + loader = DataLoader(source_dir) + loader.load() + site = SiteBuilder(out_dir, loader, mapping) + site.build_remap() + mapping.save() + return + + # --update: load existing + new zip, merge + if args.update: + zip_path = Path(args.update) + if not zip_path.exists(): + print(f"Error: {zip_path} not found.") + sys.exit(1) + + print(f"Loading existing data from {source_dir}...") + old_loader = DataLoader(source_dir) + old_loader.load() + + print(f"Extracting {zip_path}...") + tmp_dir = Path("_declaude_tmp") + new_source = extract_zip(zip_path, tmp_dir) + print(f"Loading new data from {new_source}...") + new_loader = DataLoader(new_source) + new_loader.load() + + site = SiteBuilder(out_dir, old_loader, mapping) + site.build_update(new_loader) + mapping.save() + + # Clean up temp dir + shutil.rmtree(tmp_dir, ignore_errors=True) + return + + # --build (default if nothing specified or --build explicit) + if args.build or not any([args.remap, args.update, args.map]): + if not source_dir.exists(): + print(f"Error: source directory '{source_dir}' not found.") + print(f"Use --source to specify the backup directory.") + sys.exit(1) + print(f"Loading data from {source_dir}...") + loader = DataLoader(source_dir) + loader.load() + site = SiteBuilder(out_dir, loader, mapping) + site.build_all() + mapping.save() + return + + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/mapping.json b/mapping.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/mapping.json @@ -0,0 +1 @@ +{} \ No newline at end of file