diff --git a/.idea/vcs.xml b/.idea/vcs.xml index e15a0b8..2d5c9f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -3,7 +3,6 @@ - - \ No newline at end of file + diff --git a/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md b/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md new file mode 100644 index 0000000..9b8d539 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md @@ -0,0 +1,24 @@ +# Центр задач Telegram-агента + +## Краткое описание + +Добавлена первая версия центра задач и предложений внутри `SHiNE-agent-bot-coder`. + +Бот хранит задачи и предложения в JSON-файле данных сервиса, умеет показывать список через `/tasks`, создавать задачи для игроков по фразе Айдара, принимать предложения игроков по префиксу `предложение:`, менять статусы и добавлять короткие напоминания после ответов. + +## Что проверять + +- Айдар пишет `/tasks` и видит текущий список задач и предложений без уже закрытого предложения от Димы. +- Айдар пишет `поставь задачу Милане: проверить описание SHiNE` и задача появляется в списке Миланы. +- Милана пишет `/tasks` и видит назначенную задачу. +- Игрок пишет `предложение: ...`, после чего предложение появляется у Айдара. +- Айдар меняет статус фразами вида `одобрить TC-XXXX`, `доработать TC-XXXX`, `закрыть TC-XXXX`, где `TC-XXXX` - ID существующей задачи или предложения. +- После обычного ответа бота Айдару или игроку появляется короткое напоминание, если у пользователя есть активные задачи. + +## Ожидаемый результат + +Задачи и предложения сохраняются между перезапусками сервиса, статусы меняются корректно, напоминания не мешают основному ответу Codex. + +## Статус + +pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md b/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md new file mode 100644 index 0000000..6c38c59 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md @@ -0,0 +1,31 @@ +# Рестарты и voice-настройки Telegram-агента + +## Краткое описание + +Добавлена первая версия безопасного рестарта Telegram-агента: + +- `/restart` и `/restart_service` ставят отложенный рестарт после текущей задачи и до взятия следующей; +- `/restart_hard`, `/restart_now`, `/restart_force` выполняют жёсткий рестарт сразу; +- команды рестарта доступны только Айдару; +- voice-ответы включены по умолчанию для новых пользователей; +- адаптация текста перед озвучкой стала ближе к исходному ответу и не должна менять смысл; +- скрыты отдельные команды статуса voice-функций из справки, состояние показывается через `/status`. + +## Что проверить + +1. Отправить `/restart` во время активной задачи игрока или Айдара. +2. Убедиться, что активная задача завершается, после чего сервис перезапускается до следующей задачи. +3. Отправить `/restart_hard` и убедиться, что сервис перезапускается сразу. +4. Проверить, что игрок не может выполнить команды рестарта. +5. Проверить `/status`: он показывает очередь и состояния голосовых функций. +6. Проверить нового пользователя: voice-ответы должны быть включены по умолчанию. +7. Проверить текстовый запрос пользователя с включённым voice: после текстового ответа должен прийти voice-файл. +8. Проверить, что адаптированная озвучка не превращается в другой ответ, а только убирает длинные технические строки. + +## Ожидаемый результат + +Сервис можно обновлять без потери текущей задачи через отложенный рестарт. Жёсткий рестарт остаётся аварийной командой Айдара. Voice-ответы работают для текстовых и голосовых запросов, а голосовая версия остаётся близкой к текстовой. + +## Статус + +pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md b/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md new file mode 100644 index 0000000..da25e36 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md @@ -0,0 +1,26 @@ +# Кнопки вкладки «Каналы» + +## Что сделано + +Доработана верхняя панель вкладки «Каналы»: +- при открытии нижней кнопкой «Каналы» показывается режим «Все каналы»; +- в режиме «Все каналы» справа доступны кнопка «Мои каналы» и иконка поиска канала; +- в режиме «Мои каналы» доступен переход обратно во «Все каналы», а справа показывается плюсик создания канала. + +## Что проверить + +1. Открыть вкладку «Каналы» через нижнюю навигацию. +2. Убедиться, что открыт режим «Все каналы», а плюсик создания канала не отображается. +3. Нажать иконку поиска в режиме «Все каналы». +4. Убедиться, что открывается текущий сценарий поиска каналов. +5. Нажать «Мои каналы». +6. Убедиться, что справа появился плюсик создания канала. +7. Нажать «Все каналы» или стрелку назад и проверить возврат к режиму «Все каналы». + +## Ожидаемый результат + +Кнопки верхней панели соответствуют активному режиму: поиск в «Все каналы», создание только в «Мои каналы». + +## Статус + +pending diff --git a/Dev_Docs/Pending_Features/2026-05-31_1826_qr_перенос_ключей.md b/Dev_Docs/Pending_Features/2026-05-31_1826_qr_перенос_ключей.md new file mode 100644 index 0000000..4b1fa24 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-31_1826_qr_перенос_ключей.md @@ -0,0 +1,25 @@ +# QR-перенос ключей между устройствами + +## Краткое описание + +Добавлен перенос логина и сохранённых на устройстве ключей через QR-код без дополнительного шифрования QR. + +Передаются только те ключи, которые реально есть на устройстве: `device`, `blockchain`, `root`. + +## Что проверить + +- На авторизованном устройстве открыть «Устройства» → «Подключить устройство». +- Убедиться, что недоступные ключи нельзя выбрать. +- Нажать «Показать QR-код» и проверить, что QR содержит текущий логин и выбранные доступные ключи. +- На другом устройстве открыть вход и нажать «Отсканировать QR-код». +- После сканирования проверить экран подтверждения: показывается отсканированный логин и список полученных ключей. +- Нажать «Да» и проверить, что локальная история старого логина очищена, а вход выполнен под логином из QR. +- Проверить ручной ввод QR-текста как запасной сценарий для браузеров без `BarcodeDetector`. + +## Ожидаемый результат + +Новое устройство входит под логином из QR-кода, сохраняет полученные ключи и не показывает локальную историю старого логина. + +## Статус + +pending diff --git a/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md b/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md new file mode 100644 index 0000000..238b394 --- /dev/null +++ b/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md @@ -0,0 +1,71 @@ +# Задание для Айдара: навести порядок в инструкциях агентов SHiNE + +## Кратко +Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками. + +## Зачем это нужно +Сейчас проект состоит из нескольких связанных, но разных частей: + +- основной сервер `SHiNE-server/`; +- UI `shine-UI/`; +- Solana/Anchor-модуль `shine-solana/shine/`; +- Telegram-агент-кодер `SHiNE-agent-bot-coder/`; +- TURN-сервер; +- документация `Dev_Docs/`; +- отдельные рабочие папки игроков `Players/`. + +Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций. + +## Что предлагается сделать +1. Утвердить корневой `AGENTS.md` как главный набор правил проекта. +2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`. +3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`. +4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`. +5. Явно закрепить режим игроков: + - игроки могут задавать вопросы, просить анализ, идеи и ТЗ; + - игроки не меняют код проекта напрямую; + - материалы игроков сохраняются только в `Players//`. +6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`. +7. Добавить простой процесс согласования изменений инструкций: + - Дима или другой участник готовит предложение; + - Айдар получает уведомление/заявку; + - Айдар отвечает: одобрить, отклонить или попросить доработать; + - только после одобрения агент вносит изменения в проектные инструкции. + +## Предлагаемая логика уведомления Айдару +Минимальный вариант без сложной разработки: + +1. Агент готовит текст заявки. +2. Текст отправляется Айдару в Telegram или в общий рабочий чат. +3. В заявке явно указаны варианты ответа: + - `одобрить`; + - `отклонить`; + - `доработать: ...`. +4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка. + +Более удобный вариант на будущее: + +- добавить в Telegram-бота команду или сценарий согласования задач, например: + - `/approve `; + - `/reject причина`; + - `/revise комментарий`. + +Но для начала достаточно простого текстового согласования через Telegram. + +## Что нужно от Айдара +Подтвердить, что такой порядок подходит: + +1. Корневой `AGENTS.md` остается главным правилом проекта. +2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила. +3. Игроки не меняют код напрямую, а готовят материалы и предложения. +4. Изменения инструкций выполняются только после явного одобрения Айдара. +5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок. + +## Ожидаемый результат +После одобрения: + +- агенты будут стабильнее понимать границы проекта; +- снизится риск случайных изменений не в той части системы; +- появится понятный порядок согласования задач от игроков; +- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов. + diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index 0b16f35..3137095 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -32,9 +32,9 @@ - Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. - После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать. - Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. -- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`. -- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает короткую голосовую версию без длинных хэшей, путей, команд и технического шума. -- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать. +- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию. +- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа. +- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать. - Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой. ## Правила голосовой версии ответа @@ -54,13 +54,21 @@ - Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя. - После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`. +## Центр задач и предложений +- Сервис хранит простые задачи и предложения в `data/task_center/items.json`. +- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы». +- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...». +- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`. +- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`. +- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения. + ## Локальный запуск и systemd - Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`. - Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится. - Для проверки Codex без Telegram можно использовать self-test режим сервиса. - Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`. - Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service. -- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару. +- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`. ## Правила ответа - Пиши содержательно и коротко. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index d06bd36..6dc7fc3 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -70,5 +70,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - `/new` — архивировать текущую историю и начать новый диалог. - `/voice_on` — включить озвучивание финальных ответов для текущего пользователя. - `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. -- `/voice_status` — показать состояние озвучивания для текущего пользователя. -- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново. +- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой. +- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой. +- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара). +- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара). diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index 35062e0..8b5f025 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -28,6 +28,14 @@ DEFAULT_ALLOWED_PLAYERS = ",".join([ "dimasol1:Дима", ]) +TASK_STATUS_LABELS = { + "new": "новая", + "approved": "одобрена", + "rejected": "отклонена", + "needs_work": "на доработку", + "done": "сделана", +} + def now_iso() -> str: return dt.datetime.now(dt.timezone.utc).isoformat() @@ -57,6 +65,10 @@ def parse_allowed_players(raw: str) -> dict[str, str]: return players +def compact_spaces(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()) + + def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: text = (text or "").strip() if not text: @@ -355,9 +367,12 @@ class ShinePyBotService: self.lock_file = config.data_dir / "py_app.lock" self.history_dir = config.data_dir / "history" self.history_archive_dir = self.history_dir / "archive" + self.task_center_dir = config.data_dir / "task_center" + self.task_center_file = self.task_center_dir / "items.json" self.max_processed_updates = 5000 self.queue_lock = threading.RLock() + self.task_center_lock = threading.RLock() self.stop_event = threading.Event() self.worker = threading.Thread(target=self._worker_loop, name="shine-py-bot-worker", daemon=True) @@ -387,6 +402,54 @@ class ShinePyBotService: uname = normalize_username(username) return self.cfg.allowed_players.get(uname, uname) + def _display_name(self, username: str) -> str: + uname = normalize_username(username) + if uname == self.cfg.allowed_username: + return "Айдар" + return self._player_name(uname) + + def _known_usernames(self) -> dict[str, str]: + users = {self.cfg.allowed_username: "Айдар"} + users.update(self.cfg.allowed_players) + return users + + def _find_user_by_text(self, text: str) -> str: + source = normalize_username(text) + if source in self._known_usernames(): + return source + source_lower = (text or "").strip().lower() + aliases = { + "айдар": self.cfg.allowed_username, + "айдару": self.cfg.allowed_username, + "айдара": self.cfg.allowed_username, + "милана": "malvviiina", + "милане": "malvviiina", + "милану": "malvviiina", + "миланы": "malvviiina", + "сергей": "zodiaktechnika32", + "сергею": "zodiaktechnika32", + "сергея": "zodiaktechnika32", + "иван": "oidasyda", + "ивану": "oidasyda", + "ивана": "oidasyda", + "ворон": "blackbyrd1", + "ворону": "blackbyrd1", + "ворона": "blackbyrd1", + "дима": "dimasol1", + "диме": "dimasol1", + "диму": "dimasol1", + "димы": "dimasol1", + } + for alias, username in aliases.items(): + if re.search(rf"(^|\W){re.escape(alias)}($|\W)", source_lower, flags=re.IGNORECASE): + return username + for username, name in self._known_usernames().items(): + if username and username in source_lower: + return username + if name and name.lower() in source_lower: + return username + return "" + def run(self) -> None: self._ensure_dirs() self._acquire_single_instance_lock() @@ -438,6 +501,7 @@ class ShinePyBotService: self.cfg.data_dir.mkdir(parents=True, exist_ok=True) self.history_dir.mkdir(parents=True, exist_ok=True) self.history_archive_dir.mkdir(parents=True, exist_ok=True) + self.task_center_dir.mkdir(parents=True, exist_ok=True) def _acquire_single_instance_lock(self) -> None: self.lock_file.parent.mkdir(parents=True, exist_ok=True) @@ -611,7 +675,7 @@ class ShinePyBotService: user_settings = {} settings[uname] = user_settings if not isinstance(user_settings.get("voice_replies_enabled"), bool): - user_settings["voice_replies_enabled"] = False + user_settings["voice_replies_enabled"] = True if not isinstance(user_settings.get("voice_rewrite_enabled"), bool): user_settings["voice_rewrite_enabled"] = True return user_settings @@ -659,6 +723,155 @@ class ShinePyBotService: history_path = self._current_history_file_for_user(username or self.cfg.allowed_username) self._append_history(history_path, "system_event", {"event": event_type, **payload}) + def _load_task_items(self) -> list[dict[str, Any]]: + with self.task_center_lock: + if not self.task_center_file.exists(): + return [] + try: + data = json.loads(self.task_center_file.read_text(encoding="utf-8")) + except Exception: + return [] + return data if isinstance(data, list) else [] + + def _save_task_items(self, items: list[dict[str, Any]]) -> None: + with self.task_center_lock: + self.task_center_file.parent.mkdir(parents=True, exist_ok=True) + tmp = self.task_center_file.with_suffix(".tmp") + tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(self.task_center_file) + + def _next_task_item_id(self, items: list[dict[str, Any]]) -> str: + max_num = 0 + for item in items: + raw = str(item.get("id") or "") + match = re.match(r"TC-(\d+)$", raw) + if match: + max_num = max(max_num, int(match.group(1))) + return f"TC-{max_num + 1:04d}" + + def _create_task_item( + self, + *, + kind: str, + title: str, + text: str, + source_username: str, + target_username: str, + source_message_id: int | None = None, + source_chat_id: int | None = None, + opinion: str = "", + ) -> dict[str, Any]: + items = self._load_task_items() + item = { + "id": self._next_task_item_id(items), + "kind": kind, + "status": "new", + "title": compact_spaces(title)[:160] or ("Предложение" if kind == "proposal" else "Задача"), + "text": (text or "").strip(), + "opinion": (opinion or "").strip(), + "source_username": normalize_username(source_username), + "source_name": self._display_name(source_username), + "target_username": normalize_username(target_username), + "target_name": self._display_name(target_username), + "source_chat_id": source_chat_id, + "source_message_id": source_message_id, + "created_at": now_iso(), + "updated_at": now_iso(), + } + items.append(item) + self._save_task_items(items) + self._append_history_event("task_center_item_created", { + "itemId": item["id"], + "kind": kind, + "sourceUsername": item["source_username"], + "targetUsername": item["target_username"], + "title": item["title"], + }, username=source_username) + return item + + def _task_items_for_user(self, username: str, *, include_done: bool = False) -> list[dict[str, Any]]: + uname = normalize_username(username) + items = self._load_task_items() + result = [ + item for item in items + if normalize_username(item.get("target_username")) == uname + and (include_done or item.get("status") != "done") + ] + return sorted(result, key=lambda x: str(x.get("created_at") or "")) + + def _task_center_counts_text(self, username: str) -> str: + counts: dict[str, int] = {} + for item in self._task_items_for_user(username): + status = str(item.get("status") or "new") + counts[status] = counts.get(status, 0) + 1 + active_total = sum(counts.values()) + if active_total <= 0: + return "" + parts = [] + for status in ("new", "approved", "needs_work", "rejected"): + count = counts.get(status, 0) + if count: + parts.append(f"{TASK_STATUS_LABELS.get(status, status)}: {count}") + return f"Напоминание по задачам: всего активных {active_total}; " + ", ".join(parts) + "." + + def _format_task_items(self, username: str, *, include_done: bool = False) -> str: + items = self._task_items_for_user(username, include_done=include_done) + if not items: + return f"Для {self._display_name(username)} активных задач и предложений нет." + lines = [f"Задачи и предложения для {self._display_name(username)}:"] + for item in items[:15]: + kind = "предложение" if item.get("kind") == "proposal" else "задача" + status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) + source = item.get("source_name") or item.get("source_username") or "неизвестно" + title = item.get("title") or "(без названия)" + lines.append(f"{item.get('id')} [{status}] {kind} от {source}: {title}") + if len(items) > 15: + lines.append(f"...и ещё {len(items) - 15}") + return "\n".join(lines) + + def _update_task_item_status(self, item_id: str, status: str) -> dict[str, Any] | None: + item_id = (item_id or "").strip().upper() + items = self._load_task_items() + updated = None + for item in items: + if str(item.get("id") or "").upper() == item_id: + item["status"] = status + item["updated_at"] = now_iso() + updated = item + break + if updated is not None: + self._save_task_items(items) + return updated + + def _find_first_task_item(self, *, source_username: str = "", target_username: str = "", kind: str = "") -> dict[str, Any] | None: + source = normalize_username(source_username) + target = normalize_username(target_username) + for item in self._load_task_items(): + if item.get("status") == "done": + continue + if source and normalize_username(item.get("source_username")) != source: + continue + if target and normalize_username(item.get("target_username")) != target: + continue + if kind and item.get("kind") != kind: + continue + return item + return None + + def _notify_user_about_task_item(self, username: str, item: dict[str, Any]) -> None: + chat_id = self._private_chat_id_for_user(username) + if chat_id is None: + return + kind = "предложение" if item.get("kind") == "proposal" else "задача" + source = item.get("source_name") or item.get("source_username") or "кто-то" + title = item.get("title") or "(без названия)" + status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) + if item.get("status") == "new": + text = f"У тебя новое {kind} от {source}: {title}\nID: {item.get('id')}" + else: + text = f"Обновление по {kind} {item.get('id')}: статус «{status}».\n{title}" + self._safe_send(chat_id, text) + def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None: uname = normalize_username(username) sent = self.state.get("player_welcome_sent") @@ -673,6 +886,9 @@ class ShinePyBotService: "Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n" "Команда /new начинает новую сессию и архивирует текущую историю." ) + reminder = self._task_center_counts_text(uname) + if reminder: + text = f"{text}\n\n{reminder}\nКоманда /tasks покажет список." self._safe_send(chat_id, text, reply_to=message_id) sent[uname] = now_iso() self._persist_state() @@ -829,6 +1045,9 @@ class ShinePyBotService: self._handle_command(chat_id, message_id, actor_username, text) return + if self._handle_task_center_text(chat_id, message_id, actor_username, text): + return + self._append_history(history_path, "incoming_text", { "chatId": chat_id, "messageId": message_id, @@ -933,6 +1152,104 @@ class ShinePyBotService: "active_since": None, } + def _handle_task_center_text(self, chat_id: int, message_id: int, username: str, text: str) -> bool: + source_text = (text or "").strip() + lower = source_text.lower() + is_owner = self._is_owner(username) + + if re.search(r"\b(покажи|список|какие)\b.*\b(задач|задачи|предложени)", lower): + target = username + explicit_target = self._find_user_by_text(source_text) + if is_owner and explicit_target: + target = explicit_target + self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) + return True + + if is_owner: + assign_match = re.search( + r"(?:поставь|добавь|создай|запиши)\s+(?:задачу|задание)\s+(.+?)(?::|\s+-\s+|\s+—\s+)(.+)", + source_text, + flags=re.IGNORECASE | re.DOTALL, + ) + if assign_match: + target = self._find_user_by_text(assign_match.group(1)) + body = assign_match.group(2).strip() + if target and body: + item = self._create_task_item( + kind="task", + title=body, + text=body, + source_username=username, + target_username=target, + source_message_id=message_id, + source_chat_id=chat_id, + ) + self._notify_user_about_task_item(target, item) + self._safe_send( + chat_id, + f"Задача добавлена для {self._display_name(target)}: {item['id']} — {item['title']}", + reply_to=message_id, + ) + return True + + status_match = re.search(r"\b(одобрить|отклонить|доработать|закрыть|сделано|закрыта)\b(?:\s+(.+))?", lower, flags=re.IGNORECASE) + if status_match and ("задач" in lower or "предложени" in lower or re.search(r"tc-\d+", lower, flags=re.IGNORECASE)): + action = status_match.group(1) + tail = status_match.group(2) or "" + status = { + "одобрить": "approved", + "отклонить": "rejected", + "доработать": "needs_work", + "закрыть": "done", + "сделано": "done", + "закрыта": "done", + }.get(action, "new") + id_match = re.search(r"tc-\d+", source_text, flags=re.IGNORECASE) + item = None + if id_match: + item = self._update_task_item_status(id_match.group(0), status) + else: + source_user = self._find_user_by_text(tail) + item = self._find_first_task_item( + source_username=source_user, + target_username=username, + kind="proposal" if "предложени" in lower else "", + ) + if item: + item = self._update_task_item_status(str(item.get("id")), status) + if item: + label = TASK_STATUS_LABELS.get(status, status) + self._safe_send(chat_id, f"{item.get('id')} обновлена: {label}.", reply_to=message_id) + source_user = normalize_username(item.get("source_username")) + if source_user and source_user != username: + self._notify_user_about_task_item(source_user, item) + return True + + if not is_owner: + proposal_match = re.match(r"\s*(?:предложение|идея|заявка)\s*[::-]\s*(.+)", source_text, flags=re.IGNORECASE | re.DOTALL) + if proposal_match: + body = proposal_match.group(1).strip() + if body: + item = self._create_task_item( + kind="proposal", + title=body, + text=body, + opinion="Нужно решение Айдара: одобрить, отклонить или отправить на доработку.", + source_username=username, + target_username=self.cfg.allowed_username, + source_message_id=message_id, + source_chat_id=chat_id, + ) + self._notify_user_about_task_item(self.cfg.allowed_username, item) + self._safe_send( + chat_id, + f"Предложение отправлено Айдару как {item['id']}. Статус: новая.", + reply_to=message_id, + ) + return True + + return False + def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None: lower = text.lower() command = lower.split(maxsplit=1)[0].split("@", 1)[0] @@ -946,6 +1263,15 @@ class ShinePyBotService: if command == "/queue": self._safe_send(chat_id, self._queue_text(), reply_to=message_id) return + if command in ("/tasks", "/my_tasks"): + parts = text.split(maxsplit=1) + target = username + if is_owner and len(parts) > 1: + parsed = self._find_user_by_text(parts[1]) + if parsed: + target = parsed + self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) + return if command == "/voice_on": self._set_voice_replies_enabled(username, True) self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username) @@ -956,14 +1282,8 @@ class ShinePyBotService: self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username) self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id) return - if command == "/voice_status": - status = "включено" if self._voice_replies_enabled(username) else "выключено" - rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - self._safe_send( - chat_id, - f"Озвучивание финальных ответов: {status}.\nАдаптация текста перед озвучкой: {rewrite_status}.", - reply_to=message_id, - ) + if command in ("/voice_status", "/voice_rewrite_status"): + self._safe_send(chat_id, self._status_text(username), reply_to=message_id) return if command == "/voice_rewrite_on": self._set_voice_rewrite_enabled(username, True) @@ -975,10 +1295,6 @@ class ShinePyBotService: self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username) self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id) return - if command == "/voice_rewrite_status": - status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - self._safe_send(chat_id, f"Адаптация текста перед озвучкой: {status}.", reply_to=message_id) - return if command == "/new": archived = self._rotate_history("command_new", username) self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) @@ -987,17 +1303,33 @@ class ShinePyBotService: if not is_owner: self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) return - self._append_history_event("restart_service_requested", { + self._append_history_event("restart_service_deferred_requested", { "chatId": chat_id, "messageId": message_id, "username": username, }, username=username) self._safe_send( chat_id, - "Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.", + "Отложенный рестарт принят. Если задача сейчас выполняется, сервис перезапустится после её завершения и до следующей задачи.", reply_to=message_id, ) - self._schedule_self_restart() + self._request_deferred_restart() + return + if command in ("/restart_hard", "/restart_now", "/restart_force"): + if not is_owner: + self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) + return + self._append_history_event("restart_service_hard_requested", { + "chatId": chat_id, + "messageId": message_id, + "username": username, + }, username=username) + self._safe_send( + chat_id, + "Выполняю жёсткий рестарт сервиса прямо сейчас. Активная задача, если есть, будет прервана и после старта вернётся в очередь.", + reply_to=message_id, + ) + self._schedule_self_restart("hard_restart_requested", force=True) return if command == "/stop": stopped = self._cancel_active_job("stopped_by_user") @@ -1030,6 +1362,7 @@ class ShinePyBotService: "Доступные команды:", "/status — активная задача и размер очереди", "/queue — список задач в очереди", + "/tasks — список ваших задач и предложений", "/stop — остановить текущую задачу", "/cancel — удалить задачу по id (префикс) или все", "/new — архивировать историю и начать новую", @@ -1037,11 +1370,12 @@ class ShinePyBotService: "/voice_off — выключить озвучивание финальных ответов", "/voice_rewrite_on — включить адаптацию текста перед озвучкой", "/voice_rewrite_off — выключить адаптацию текста перед озвучкой", - "/voice_status — показать состояние голосовых функций", "/help — эта справка", ] if is_owner: - lines.insert(-1, "/restart_service — перезапустить сервис через systemd") + lines.insert(-1, "/tasks <пользователь> — список задач игрока") + lines.insert(-1, "/restart — отложенный рестарт после текущей задачи") + lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас") return "\n".join(lines) def _status_text(self, username: str) -> str: @@ -1054,8 +1388,9 @@ class ShinePyBotService: f"Голосовые ответы: {voice_status}\n" f"Адаптация текста перед озвучкой: {rewrite_status}" ) + restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else "" if not active: - return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}" + return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}{restart_text}" elapsed = int(time.time() - (self.active_job_started_at or time.time())) return ( f"Статус: активная задача #{active.get('num', '?')}\n" @@ -1063,7 +1398,7 @@ class ShinePyBotService: f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" f"Выполняется: {elapsed}с\n" f"Pending: {pending}\n" - f"{settings_text}" + f"{settings_text}{restart_text}" ) def _queue_text(self) -> str: @@ -1115,6 +1450,9 @@ class ShinePyBotService: def _worker_loop(self) -> None: while not self.stop_event.is_set(): + if self.restart_requested: + self._exit_for_restart("deferred_restart_before_next_job") + return job = None with self.queue_lock: for item in self.queue: @@ -1135,6 +1473,9 @@ class ShinePyBotService: self._process_job(job) self.active_job_id = None self.active_job_started_at = None + if self.restart_requested: + self._exit_for_restart("deferred_restart_after_job") + return def _process_job(self, job: dict[str, Any]) -> None: job_id = job["id"] @@ -1162,6 +1503,7 @@ class ShinePyBotService: self._safe_send(chat_id, chunk, reply_to=message_id) self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) self._send_private_job_public_report(job, answer) + self._send_task_center_reminder(job) if self._voice_replies_enabled(job.get("username") or ""): self._send_voice_reply_for_answer(job, answer, history_path, job_id) self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id) @@ -1390,6 +1732,15 @@ class ShinePyBotService: self.queue = [j for j in self.queue if j.get("id") != job_id] self._persist_queue() + def _send_task_center_reminder(self, job: dict[str, Any]) -> None: + if job.get("chat_type") != "private": + return + username = job.get("username") or "" + reminder = self._task_center_counts_text(username) + if not reminder: + return + self._safe_send(int(job["chat_id"]), reminder, reply_to=int(job["message_id"])) + def _remember_public_report_chat(self, chat_id: int) -> None: if self.state.get("public_report_chat_id") == chat_id: return @@ -1575,14 +1926,36 @@ class ShinePyBotService: print(f"[py-bot] sendMessage error: {e}", flush=True) return None - def _schedule_self_restart(self) -> None: + def _request_deferred_restart(self) -> None: if self.restart_requested: return self.restart_requested = True + self._append_history_event("restart_service_deferred_scheduled", {}) + with self.queue_lock: + has_active = any(j.get("status") == "active" for j in self.queue) + if not has_active: + threading.Thread( + target=lambda: self._exit_for_restart("deferred_restart_no_active_job"), + name="shine-py-bot-deferred-restart", + daemon=True, + ).start() + + def _exit_for_restart(self, reason: str) -> None: + print(f"[py-bot] restart now: {reason}", flush=True) + self._append_history_event("restart_service_executing", {"reason": reason}) + time.sleep(0.5) + os._exit(0) + + def _schedule_self_restart(self, reason: str = "restart_requested", *, force: bool = False) -> None: + if self.restart_requested and not force: + return + self.restart_requested = True def restart() -> None: time.sleep(1.5) - print("[py-bot] restart requested by Telegram command", flush=True) + print(f"[py-bot] restart requested by Telegram command: {reason}", flush=True) + if force: + self._stop_active_codex_process() os._exit(0) threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() @@ -1725,10 +2098,11 @@ class ShinePyBotService: { "role": "system", "content": ( - "Ты готовишь короткую русскую голосовую версию финального ответа технического агента. " - "Сохрани итог, важные предупреждения и действия. Убери длинные пути, хэши, команды, номера версий, " - "JSON, списки файлов и другие строки, которые плохо воспринимаются на слух. " - "Не добавляй новых фактов. Пиши естественно, кратко, без markdown." + "Ты готовишь русскую версию финального ответа технического агента для озвучивания. " + "Не пересказывай заново и не меняй смысл: сохрани порядок мыслей, итог, предупреждения, статусы и важные действия. " + "Мягко убери только то, что плохо воспринимается на слух: длинные пути, хэши, ID, команды, JSON, " + "длинные списки файлов, точные размеры и счётчики символов. Если деталь важна, замени её коротким описанием. " + "Не добавляй новых фактов. Пиши естественно, без markdown, близко к исходному тексту." ), }, { diff --git a/SHiNE-server/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java b/SHiNE-server/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java index 714590f..2804a39 100644 --- a/SHiNE-server/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java +++ b/SHiNE-server/src/test/java/test/it/IT_DeployRestartNoCleanNoTestsMain.java @@ -43,7 +43,10 @@ public class IT_DeployRestartNoCleanNoTestsMain { } private static void ensureSudoNoPasswordOrThrow() { - int code = ssh("sudo -n systemctl status " + SERVICE_NAME + " >/dev/null 2>&1"); + // Проверяем именно возможность sudo без пароля. + // systemctl status может возвращать non-zero для inactive/failed сервиса, + // и это не должно считаться проблемой прав доступа. + int code = ssh("sudo -n true"); if (code == 0) return; throw new RuntimeException( "Remote sudo requires password for " + REMOTE_USER + "@" + REMOTE_HOST diff --git a/VERSION.properties b/VERSION.properties index 22e509d..94754bc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.102 -server.version=1.2.96 +client.version=1.2.103 +server.version=1.2.97 diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 593589f..f661fc0 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -9,12 +9,6 @@ const ITEMS = [ { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, { pageId: 'profile-view', label: 'Профиль', icon: '👤' }, ]; -const CHANNEL_HOLD_MS = 260; -const CHANNEL_MODES = Object.freeze([ - { key: 'feed', label: 'Каналы' }, - { key: 'dialogs', label: 'Чаты' }, - { key: 'my', label: 'Мои' }, -]); function getTotalUnreadMessages() { const chats = Object.values(state.chats || {}); @@ -91,7 +85,7 @@ export function renderToolbar(currentPageId, navigate) { btn.append(badge); } if (item.pageId === 'channels-list') { - installChannelsHoldSwitcher(btn, navigate); + btn.addEventListener('click', () => navigate('channels-list/feed')); } else { btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate)); } @@ -100,90 +94,3 @@ export function renderToolbar(currentPageId, navigate) { return root; } - -function installChannelsHoldSwitcher(button, navigate) { - let holdTimer = 0; - let pressed = false; - let holdActive = false; - let overlay = null; - let selectedMode = 'feed'; - - const clearTimer = () => { - if (holdTimer) { - window.clearTimeout(holdTimer); - holdTimer = 0; - } - }; - - const closeOverlay = () => { - if (overlay) overlay.remove(); - overlay = null; - holdActive = false; - }; - - const setSelectedModeByX = (clientX) => { - if (!overlay) return; - const rect = overlay.getBoundingClientRect(); - const part = rect.width / 3; - const localX = Math.max(0, Math.min(rect.width - 1, clientX - rect.left)); - const index = Math.max(0, Math.min(2, Math.floor(localX / Math.max(1, part)))); - selectedMode = CHANNEL_MODES[index].key; - const buttons = overlay.querySelectorAll('.toolbar-channels-hold-item'); - buttons.forEach((el, idx) => { - el.classList.toggle('is-active', idx === index); - }); - }; - - const openOverlay = () => { - const rect = button.getBoundingClientRect(); - overlay = document.createElement('div'); - overlay.className = 'toolbar-channels-hold-overlay'; - overlay.innerHTML = CHANNEL_MODES.map((mode) => ( - `` - )).join(''); - overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`; - overlay.style.top = `${Math.round(rect.top - 12)}px`; - document.body.append(overlay); - holdActive = true; - }; - - button.addEventListener('pointerdown', (event) => { - pressed = true; - holdActive = false; - selectedMode = 'feed'; - clearTimer(); - holdTimer = window.setTimeout(() => { - if (!pressed) return; - openOverlay(); - setSelectedModeByX(event.clientX); - }, CHANNEL_HOLD_MS); - }); - - button.addEventListener('pointermove', (event) => { - if (holdActive) setSelectedModeByX(event.clientX); - }); - - button.addEventListener('pointerup', () => { - clearTimer(); - const wasHold = holdActive; - const mode = selectedMode; - pressed = false; - closeOverlay(); - if (wasHold) { - navigate(`channels-list/${mode}`); - return; - } - navigate('channels-list/feed'); - }); - - button.addEventListener('pointercancel', () => { - clearTimer(); - pressed = false; - closeOverlay(); - }); - - button.addEventListener('contextmenu', (event) => { - event.preventDefault(); - }); -} - diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 368d0cc..292ba26 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -1210,6 +1210,16 @@ export function render({ navigate, route }) { rerenderList(); }); + const topBarRight = document.createElement('div'); + topBarRight.className = 'channels-top-right'; + + const findChannelBtn = document.createElement('button'); + findChannelBtn.type = 'button'; + findChannelBtn.className = 'icon-btn channels-top-search-btn'; + findChannelBtn.textContent = '🔎'; + findChannelBtn.setAttribute('aria-label', 'Найти канал'); + findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate })); + const createInMyBtn = document.createElement('button'); createInMyBtn.type = 'button'; createInMyBtn.className = 'icon-btn channels-top-add-btn'; @@ -1217,8 +1227,9 @@ export function render({ navigate, route }) { createInMyBtn.setAttribute('aria-label', 'Создать канал'); createInMyBtn.addEventListener('click', () => navigate('add-channel-view')); - topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn); - topBarEl.append(topBarLeft, createInMyBtn); + topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle); + topBarRight.append(myChannelsBtn, findChannelBtn, createInMyBtn); + topBarEl.append(topBarLeft, topBarRight); const bottomCta = document.createElement('button'); bottomCta.type = 'button'; @@ -1252,12 +1263,14 @@ export function render({ navigate, route }) { allChannelsBtn.style.display = ''; myChannelsBtn.style.display = 'none'; topTitle.textContent = 'Мои каналы'; + findChannelBtn.style.display = 'none'; createInMyBtn.style.display = ''; } else { backToFeedBtn.style.display = 'none'; allChannelsBtn.style.display = 'none'; myChannelsBtn.style.display = isGuest ? 'none' : ''; - topTitle.textContent = 'Каналы'; + topTitle.textContent = 'Все каналы'; + findChannelBtn.style.display = ''; createInMyBtn.style.display = 'none'; } diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index 62411ad..80787b2 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -1,5 +1,6 @@ import { renderHeader } from '../components/header.js'; import { state } from '../state.js'; +import { loadEncryptedUserSecrets } from '../services/key-vault.js'; export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' }; @@ -21,6 +22,7 @@ export function render({ navigate }) { +

Проверяем ключи на этом устройстве...

@@ -33,6 +35,8 @@ export function render({ navigate }) { const rootToggle = card.querySelector('#connect-root'); const blockchainToggle = card.querySelector('#connect-blockchain'); const deviceToggle = card.querySelector('#connect-device'); + const statusEl = card.querySelector('#connect-keys-status'); + const openQrBtn = card.querySelector('#open-qr'); deviceToggle.checked = true; rootToggle.addEventListener('change', () => { @@ -85,6 +89,47 @@ export function render({ navigate }) { card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view')); card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view')); + (async () => { + try { + if (!state.session.login || !state.session.storagePwdInMemory) { + throw new Error('Нет активной сессии'); + } + const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); + const hasRoot = Boolean(savedKeys.rootKey); + const hasBlockchain = Boolean(savedKeys.blockchainKey); + const hasDevice = Boolean(savedKeys.deviceKey); + + rootToggle.disabled = !hasRoot; + blockchainToggle.disabled = !hasBlockchain; + deviceToggle.disabled = true; + state.deviceConnect.root = hasRoot && rootToggle.checked; + state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked; + state.deviceConnect.device = hasDevice; + rootToggle.checked = state.deviceConnect.root; + blockchainToggle.checked = state.deviceConnect.blockchain; + deviceToggle.checked = hasDevice; + openQrBtn.disabled = !hasDevice; + + const available = [ + hasDevice ? 'device' : '', + hasBlockchain ? 'blockchain' : '', + hasRoot ? 'root' : '', + ].filter(Boolean); + statusEl.textContent = available.length + ? `На этом устройстве доступны: ${available.join(', ')}.` + : 'На этом устройстве нет сохранённых ключей для передачи.'; + } catch { + rootToggle.disabled = true; + blockchainToggle.disabled = true; + deviceToggle.checked = false; + state.deviceConnect.root = false; + state.deviceConnect.blockchain = false; + state.deviceConnect.device = false; + openQrBtn.disabled = true; + statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.'; + } + })(); + helpModal.addEventListener('click', (event) => { const target = event.target; if (target instanceof HTMLElement && target.dataset.close === 'true') { diff --git a/shine-UI/js/pages/device-qr-view.js b/shine-UI/js/pages/device-qr-view.js index 88afc7b..4c09e69 100644 --- a/shine-UI/js/pages/device-qr-view.js +++ b/shine-UI/js/pages/device-qr-view.js @@ -1,6 +1,11 @@ import { renderHeader } from '../components/header.js'; -import { profile } from '../mock-data.js'; import { state } from '../state.js'; +import { loadEncryptedUserSecrets } from '../services/key-vault.js'; +import { + describeTransferKeys, + makeKeyTransferText, + renderQrSvg, +} from '../services/qr-key-transfer-service.js'; export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' }; @@ -8,11 +13,6 @@ export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; - const selectedKeys = []; - if (state.deviceConnect.root) selectedKeys.push('root key'); - if (state.deviceConnect.blockchain) selectedKeys.push('blockchain key'); - if (state.deviceConnect.device) selectedKeys.push('device key'); - screen.append( renderHeader({ title: 'Показать QR-код', @@ -23,12 +23,44 @@ export function render({ navigate }) { const card = document.createElement('div'); card.className = 'card stack qr-card'; card.innerHTML = ` - QR-код для подключения -

Логин пользователя: ${profile.login}

-

Передаваемые ключи: ${selectedKeys.join(', ')}

+
+

Логин: ...

+

Ключи: ...

+ `; + const qrEl = card.querySelector('#device-transfer-qr'); + const loginEl = card.querySelector('#device-transfer-login'); + const keysEl = card.querySelector('#device-transfer-keys'); + const statusEl = card.querySelector('#device-transfer-status'); + + (async () => { + try { + if (!state.session.login || !state.session.storagePwdInMemory) { + throw new Error('Нет активной сессии для чтения ключей'); + } + const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); + const keys = { + deviceKey: savedKeys.deviceKey || '', + blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '', + rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '', + }; + if (!keys.deviceKey) throw new Error('На этом устройстве нет device key'); + + const qrText = makeKeyTransferText({ login: state.session.login, keys }); + qrEl.innerHTML = renderQrSvg(qrText); + loginEl.textContent = `Логин: ${state.session.login}`; + keysEl.textContent = `Ключи: ${describeTransferKeys(keys).join(', ')}`; + } catch (error) { + qrEl.textContent = ''; + loginEl.textContent = 'Логин: нет данных'; + keysEl.textContent = 'Ключи: нет данных'; + statusEl.textContent = error?.message || 'Не удалось подготовить QR-код.'; + statusEl.style.display = ''; + } + })(); + card.querySelector('#qr-ok').addEventListener('click', () => navigate('connect-device-view')); screen.append(card); diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 4242c9b..9360e3e 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -36,9 +36,11 @@ export function render({ navigate }) { actions.className = 'card stack'; actions.innerHTML = ` + `; + actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view')); actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view')); const sessionsBlock = document.createElement('div'); diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index f1becd8..d48694d 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -1,47 +1,238 @@ import { renderHeader } from '../components/header.js'; +import { + authService, + authorizeSession, + clearAuthMessages, + clearBrowserClientData, + refreshSessions, + setAuthBusy, + setAuthError, + setAuthInfo, + state, + terminateCurrentSession, +} from '../state.js'; +import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js'; +import { clearStoredMessages } from '../services/message-store.js'; +import { + describeTransferKeys, + parseKeyTransferText, +} from '../services/qr-key-transfer-service.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; -export const pageMeta = { id: 'login-camera-view', title: 'Войти по камере', showAppChrome: false }; +export const pageMeta = { id: 'login-camera-view', title: 'Войти по QR-коду', showAppChrome: false }; + +function canUseBarcodeDetector() { + return typeof window.BarcodeDetector === 'function'; +} + +async function createQrDetector() { + if (!canUseBarcodeDetector()) return null; + try { + const formats = await window.BarcodeDetector.getSupportedFormats?.(); + if (Array.isArray(formats) && !formats.includes('qr_code')) return null; + } catch { + // Некоторые браузеры не реализуют getSupportedFormats, но сам detector работает. + } + return new window.BarcodeDetector({ formats: ['qr_code'] }); +} + +function setStatus(statusEl, message, kind = 'info') { + statusEl.classList.toggle('is-unavailable', kind === 'error'); + statusEl.classList.toggle('is-available', kind !== 'error'); + statusEl.textContent = message; + statusEl.style.display = message ? '' : 'none'; +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderParsedTransfer(resultEl, transfer) { + const keys = describeTransferKeys(transfer.keys); + resultEl.innerHTML = ` +

Отсканированный логин: ${escapeHtml(transfer.login)}

+

Получены ключи: ${escapeHtml(keys.join(', '))}

+

Войти под этим логином и очистить локальную историю старого логина?

+ `; +} export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; + clearAuthMessages(); + const frame = document.createElement('div'); frame.className = 'camera-shell'; frame.innerHTML = `
-
Наведите QR-код в рамку
+
Наведите QR-код переноса ключей в рамку
+ `; + const manualCard = document.createElement('details'); + manualCard.className = 'card stack'; + manualCard.innerHTML = ` + Ввести QR-текст вручную + + + `; + + const resultCard = document.createElement('div'); + resultCard.className = 'card stack'; + resultCard.style.display = 'none'; + resultCard.innerHTML = ` +
+
+ + +
+ `; + + const status = document.createElement('p'); + status.className = 'status-line is-unavailable'; + status.style.display = 'none'; + + const backButton = document.createElement('button'); + backButton.className = 'ghost-btn'; + backButton.type = 'button'; + backButton.textContent = 'Назад'; + const video = frame.querySelector('video'); + const cameraError = frame.querySelector('#login-camera-error'); + const manualInput = manualCard.querySelector('#login-qr-manual'); + const parseManualButton = manualCard.querySelector('#login-qr-manual-parse'); + const resultEl = resultCard.querySelector('#login-qr-result'); + const cancelButton = resultCard.querySelector('#login-qr-cancel'); + const confirmButton = resultCard.querySelector('#login-qr-confirm'); + let stream = null; + let detector = null; + let scanTimer = 0; + let scannedTransfer = null; + let stopped = false; const stopCamera = () => { + stopped = true; + if (scanTimer) { + window.clearTimeout(scanTimer); + scanTimer = 0; + } if (stream) { stream.getTracks().forEach((track) => track.stop()); stream = null; } }; - if (navigator.mediaDevices?.getUserMedia) { - navigator.mediaDevices - .getUserMedia({ video: { facingMode: 'environment' }, audio: false }) - .then((nextStream) => { - stream = nextStream; - video.srcObject = nextStream; - }) - .catch(() => { - frame.insertAdjacentHTML('beforeend', '
Не удалось открыть камеру. Проверьте разрешения браузера.
'); - }); - } else { - frame.insertAdjacentHTML('beforeend', '
Камера не поддерживается в этом браузере.
'); - } + const showTransfer = (transfer) => { + scannedTransfer = transfer; + stopCamera(); + renderParsedTransfer(resultEl, transfer); + resultCard.style.display = ''; + setStatus(status, '', 'info'); + }; + + const parseTransferText = (text) => { + try { + const transfer = parseKeyTransferText(text); + if (!transfer.keys.deviceKey) { + throw new Error('В QR-коде нет device key для входа'); + } + showTransfer(transfer); + } catch (error) { + setStatus(status, error?.message || 'Не удалось прочитать QR-код.', 'error'); + } + }; + + const scanLoop = async () => { + if (stopped || !detector || !video || video.readyState < 2) { + if (!stopped) scanTimer = window.setTimeout(scanLoop, 250); + return; + } + try { + const codes = await detector.detect(video); + const text = String(codes?.[0]?.rawValue || '').trim(); + if (text) { + parseTransferText(text); + return; + } + } catch { + // Ошибки отдельных кадров игнорируем, камера продолжит сканирование. + } + if (!stopped) scanTimer = window.setTimeout(scanLoop, 300); + }; + + const startCamera = async () => { + try { + detector = await createQrDetector(); + if (!detector) { + throw new Error('Этот браузер не поддерживает сканирование QR через камеру. Используйте ручной ввод QR-текста.'); + } + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Камера не поддерживается в этом браузере.'); + } + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }); + video.srcObject = stream; + await video.play?.(); + scanLoop(); + } catch (error) { + cameraError.textContent = error?.message || 'Не удалось открыть камеру. Проверьте разрешения браузера.'; + cameraError.style.display = ''; + setStatus(status, cameraError.textContent, 'error'); + } + }; + + parseManualButton.addEventListener('click', () => parseTransferText(manualInput.value)); + cancelButton.addEventListener('click', () => { + scannedTransfer = null; + resultCard.style.display = 'none'; + stopped = false; + void startCamera(); + }); + confirmButton.addEventListener('click', async () => { + if (!scannedTransfer) return; + confirmButton.disabled = true; + cancelButton.disabled = true; + setAuthBusy(true); + setAuthError(''); + setStatus(status, 'Входим по QR-коду...', 'info'); + try { + await authService.reconnect(state.entrySettings.shineServer); + const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys); + await clearStoredMessages().catch(() => {}); + clearBrowserClientData(); + await clearClientAuthData().catch(() => {}); + await terminateCurrentSession(); + await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys); + await authService.persistSessionMaterial(session.login, session.sessionMaterial); + const resumed = await authService.resumeSession(session.login, session.sessionId); + authorizeSession({ + login: resumed.login || session.login, + sessionId: resumed.sessionId || session.sessionId, + storagePwd: resumed.storagePwd || session.storagePwd, + }); + state.loginDraft.login = resumed.login || session.login; + state.loginDraft.password = ''; + await refreshSessions(); + setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`); + navigate('profile-view'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось войти по QR-коду.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + setAuthBusy(false); + confirmButton.disabled = false; + cancelButton.disabled = false; + } + }); - const backButton = document.createElement('button'); - backButton.className = 'ghost-btn'; - backButton.type = 'button'; - backButton.textContent = 'Назад'; backButton.addEventListener('click', () => { stopCamera(); navigate('login-view'); @@ -49,7 +240,7 @@ export function render({ navigate }) { screen.append( renderHeader({ - title: 'Войти по камере', + title: 'Войти по QR-коду', leftAction: { label: '←', onClick: () => { @@ -59,9 +250,13 @@ export function render({ navigate }) { }, }), frame, + manualCard, + resultCard, + status, backButton, ); + void startCamera(); screen.cleanup = stopCamera; return screen; } diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js index 0a6c30f..c6590c0 100644 --- a/shine-UI/js/pages/login-view.js +++ b/shine-UI/js/pages/login-view.js @@ -39,7 +39,7 @@ export function render({ navigate }) { const cameraButton = document.createElement('button'); cameraButton.className = 'primary-btn'; cameraButton.type = 'button'; - cameraButton.textContent = 'Войти по камере'; + cameraButton.textContent = 'Отсканировать QR-код'; cameraButton.addEventListener('click', () => navigate('login-camera-view')); const loginButton = document.createElement('button'); diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index a2a41ee..b9a8af0 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -7,7 +7,6 @@ import { getBalanceSol, getTopupSiteUrl, getWalletFromStoredDeviceKey, - requestAirdropSol, transferSol, } from '../services/solana-wallet-service.js'; import { @@ -738,32 +737,7 @@ export function render({ navigate }) { setStatus('Кошелёк не инициализирован.'); return; } - - const openSite = window.confirm( - 'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).', - ); - if (openSite) { - window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer'); - setStatus('Открыт сайт пополнения. Пока также доступно тестовое пополнение.'); - return; - } - - topupBtn.disabled = true; - try { - const drop = await requestAirdropSol({ - endpoint: state.entrySettings.solanaServer, - address: walletAddress, - amountSol: 1, - }); - if (modeToken !== activeModeToken) return; - setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`); - await refreshBalance(); - } catch (error) { - if (modeToken !== activeModeToken) return; - setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`); - } finally { - topupBtn.disabled = false; - } + window.location.assign(getTopupSiteUrl(walletAddress)); }); content.append(backBtn, card, actions, generatedCard); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 0fdd896..65afa51 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -8,6 +8,7 @@ import { exportPkcs8B64, generateEd25519Pair, importPkcs8Ed25519, + publicKeyB64FromPkcs8Ed25519, randomBase64, sha256Bytes, signBytes, @@ -857,6 +858,23 @@ export class AuthService { return { ...session, keyBundle }; } + async createSessionFromImportedSecrets(login, secrets) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('В QR-коде нет логина'); + const deviceKey = String(secrets?.deviceKey || '').trim(); + if (!deviceKey) throw new Error('В QR-коде нет device key для входа'); + + const privateKey = await importPkcs8Ed25519(deviceKey); + const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey); + const session = await this.createAuthSession(cleanLogin, { + devicePair: { + privateKey, + publicKeyB64, + }, + }); + return session; + } + async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) { let currentSecrets = {}; try { diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 63818dd..a7f0bc8 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -254,6 +254,13 @@ export async function importPkcs8Ed25519(pkcs8B64) { return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']); } +export async function publicKeyB64FromPkcs8Ed25519(pkcs8B64) { + const privateKey = await getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, true, ['sign']); + const jwk = await getSubtleApi().exportKey('jwk', privateKey); + if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519'); + return bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))); +} + export async function signBase64(privateKey, text) { const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text)); return bytesToBase64(new Uint8Array(signature)); diff --git a/shine-UI/js/services/qr-key-transfer-service.js b/shine-UI/js/services/qr-key-transfer-service.js new file mode 100644 index 0000000..7f74b59 --- /dev/null +++ b/shine-UI/js/services/qr-key-transfer-service.js @@ -0,0 +1,87 @@ +import qrcode from '../vendor-qrcode-generator.js'; + +const TRANSFER_PREFIX = 'shine-key-transfer-v1:'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function bytesToBase64Url(bytes) { + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64UrlToBytes(value) { + const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +export function keyLabel(id) { + if (id === 'root') return 'root'; + if (id === 'blockchain') return 'blockchain'; + if (id === 'device') return 'device'; + return id; +} + +export function describeTransferKeys(keys = {}) { + const out = []; + if (keys.deviceKey) out.push('device'); + if (keys.blockchainKey) out.push('blockchain'); + if (keys.rootKey) out.push('root'); + return out; +} + +export function makeKeyTransferText({ login, keys }) { + const payload = { + v: 1, + type: 'shine-key-transfer', + login: String(login || '').trim(), + keys: { + deviceKey: String(keys?.deviceKey || ''), + blockchainKey: String(keys?.blockchainKey || ''), + rootKey: String(keys?.rootKey || ''), + }, + createdAtMs: Date.now(), + }; + const json = JSON.stringify(payload); + return `${TRANSFER_PREFIX}${bytesToBase64Url(encoder.encode(json))}`; +} + +export function parseKeyTransferText(text) { + const raw = String(text || '').trim(); + if (!raw.startsWith(TRANSFER_PREFIX)) { + throw new Error('Это не QR-код переноса ключей SHiNE'); + } + const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length))); + const payload = JSON.parse(json); + if (payload?.v !== 1 || payload?.type !== 'shine-key-transfer') { + throw new Error('Неподдерживаемый формат QR-кода'); + } + const login = String(payload.login || '').trim(); + if (!login) throw new Error('В QR-коде нет логина'); + const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {}; + if (!keys.deviceKey && !keys.blockchainKey && !keys.rootKey) { + throw new Error('В QR-коде нет ключей'); + } + return { + login, + keys: { + deviceKey: String(keys.deviceKey || ''), + blockchainKey: String(keys.blockchainKey || ''), + rootKey: String(keys.rootKey || ''), + }, + keyTypes: describeTransferKeys(keys), + }; +} + +export function renderQrSvg(text, { cellSize = 4, margin = 4 } = {}) { + const qr = qrcode(0, 'L'); + qr.addData(String(text || ''), 'Byte'); + qr.make(); + return qr.createSvgTag(cellSize, margin); +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 4d7dfc2..de16c63 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -186,7 +186,7 @@ function persistEntrySettings(settings) { } } -function clearBrowserClientData() { +export function clearBrowserClientData() { const localKeys = [ SESSION_STORAGE_KEY, REACTIONS_STORAGE_KEY, diff --git a/shine-UI/js/vendor-qrcode-generator.js b/shine-UI/js/vendor-qrcode-generator.js new file mode 100644 index 0000000..98ce986 --- /dev/null +++ b/shine-UI/js/vendor-qrcode-generator.js @@ -0,0 +1,2299 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); + +export default qrcode; diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 2d79815..e532458 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -2749,6 +2749,14 @@ textarea.input { min-width: 0; } +.channels-top-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .channels-top-title { font-size: 16px; line-height: 1.2; @@ -2762,7 +2770,8 @@ textarea.input { } .channels-top-back-btn, -.channels-top-add-btn { +.channels-top-add-btn, +.channels-top-search-btn { width: 36px; height: 36px; min-width: 36px;