Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
aab9e7d
fix(search-table-state): restore documents after clearing query
pntech-dev Mar 17, 2026
b529f8e
fix(config-loading): harden config parsing for empty and invalid yaml
pntech-dev Mar 17, 2026
25c3e6e
fix(update-flow): handle download cancelation and cleanup consistently
pntech-dev Mar 17, 2026
8ebddfc
fix(settings-schema): migrate legacy search filters to current format
pntech-dev Mar 17, 2026
41bb933
fix(error-handling): improve http detail extraction and fallback mess…
pntech-dev Mar 17, 2026
4e998bb
fix(main-stability): prevent editor open crash and harden token lookup
pntech-dev Mar 17, 2026
a89b47a
fix(auth-stability): harden validation state and confirmation cancel …
pntech-dev Mar 17, 2026
24e24b5
fix(document-editor-stability): harden session-token edge cases
pntech-dev Mar 17, 2026
319a808
fix(api-stability): handle whitespace-empty successful responses safely
pntech-dev Mar 17, 2026
bf1ee8b
fix(update-stability): guarantee cancel completion and cleanup in dow…
pntech-dev Mar 17, 2026
7c5422d
fix(notification-stability): keep toast stack consistent on manual close
pntech-dev Mar 17, 2026
33b063a
fix(notification-lifecycle): handle missing main window safely
pntech-dev Mar 17, 2026
e3b7534
fix(auth-session): block continuation on incomplete saved user session
pntech-dev Mar 17, 2026
c387ef7
fix(toolbar-refresh): keep selected category stable during data update
pntech-dev Mar 17, 2026
9cd4d59
fix(editor-toolbar): avoid false dirty state on no-op duplicate/delete
pntech-dev Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,11 @@ Allows authorized users to create, edit, and manage document pages. Support for

---

## 💡 What's New (v0.2.1) - Stability & Profile Improvements
## 🛠 What's New (v0.2.16)

This patch release improves reliability and profile editing behavior.

### 🛠 Improved

- Fixed a crash when opening the profile window for accounts without a filled display name.
- Improved reliability of profile data updates after backend deployment updates.
- Improved handling of empty values in document and page names.
- Improved stability of search and document loading in edge cases.
- Improved toolbar behavior in document edit/create windows.
- `Duplicate` and `Delete` now only mark a document as changed when pages were actually modified.
- Reduced accidental “unsaved changes” state when pressing toolbar actions with no selected pages.

---

Expand Down
13 changes: 4 additions & 9 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,11 @@

---

## 💡 Что нового (v0.2.1) - Стабильность и профиль
## 🛠 Что нового (v0.2.16)

Это патч-обновление повышает надежность и улучшает поведение при работе с профилем.

### 🛠 Улучшено

- Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени.
- Повышена надежность обновления данных профиля после обновлений backend-сервиса.
- Улучшена обработка пустых значений в названиях документов и страниц.
- Повышена стабильность поиска и загрузки документов в пограничных сценариях.
- Улучшено поведение панели инструментов в окнах создания и редактирования документа.
- `Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц.
- Снижен риск ложного состояния “есть несохранённые изменения” при нажатии действий без выбранных страниц.

---

Expand Down
8 changes: 7 additions & 1 deletion api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,4 +539,10 @@ def _request(self, method: str, url: str, timeout: int = 10, **kwargs) -> dict:
request.raise_for_status()
if request.status_code == 204 or not request.content:
return {}
return request.json()
try:
return request.json()
except ValueError:
# Some backends may return 200 with an empty/whitespace body.
if not (request.text or "").strip():
return {}
raise
2 changes: 1 addition & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough")


APP_VERSION = "0.2.1"
APP_VERSION = "0.2.16"

class Application:
"""
Expand Down
30 changes: 28 additions & 2 deletions core/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class UpdateDownloader(QThread):
"""Thread for downloading the update file."""
progress = pyqtSignal(int, int) # downloaded_bytes, total_bytes
finished = pyqtSignal(str) # путь к скачанному файлу
canceled = pyqtSignal()
error = pyqtSignal(str)

def __init__(self, url: str, expected_size: int = 0):
Expand All @@ -91,6 +92,7 @@ def stop(self):
self._is_running = False

def run(self):
path = None
try:
# Use requests context manager to ensure connection closure
with requests.get(self.url, stream=True, timeout=30, allow_redirects=True) as response:
Expand Down Expand Up @@ -121,6 +123,7 @@ def run(self):
os.remove(path)
except OSError:
pass
self.canceled.emit()
return

if chunk:
Expand All @@ -138,10 +141,24 @@ def run(self):
self.progress.emit(downloaded_size, total_size)
last_emitted_size = downloaded_size

if self._is_running:
self.finished.emit(path)
if not self._is_running:
try:
os.remove(path)
except OSError:
pass
self.canceled.emit()
return

self.finished.emit(path)

except Exception as e:
if path and not self._is_running:
try:
os.remove(path)
except OSError:
pass
self.canceled.emit()
return
logger.error(f"Download failed: {e}")
self.error.emit(str(e))

Expand Down Expand Up @@ -193,6 +210,7 @@ def _start_download(self, url, size):
self._downloader = UpdateDownloader(url, size)
self._downloader.progress.connect(self.progress_dialog.set_progress)
self._downloader.finished.connect(self._on_download_finished)
self._downloader.canceled.connect(self._on_download_canceled)
self._downloader.error.connect(self._on_download_error)

self.progress_dialog.canceled.connect(self._downloader.stop)
Expand All @@ -201,13 +219,20 @@ def _start_download(self, url, size):

def _on_download_error(self, error_msg):
self.progress_dialog.close()
self._downloader = None
# If main window is not created yet (check on startup), NotificationService won't work
if NotificationService().main_window:
NotificationService().show_toast("error", "Ошибка", f"Ошибка скачивания: {error_msg}")
else:
# Use standard QMessageBox as a fallback
QMessageBox.critical(self.parent_widget, "Ошибка", f"Ошибка скачивания:\n{error_msg}")

def _on_download_canceled(self):
self.progress_dialog.close()
self._downloader = None
if NotificationService().main_window:
NotificationService().show_toast("info", "Обновление", "Скачивание обновления отменено.")

def _on_download_finished(self, file_path):
# Force set 100% so user sees completion
self.progress_dialog.set_progress(100, 100)
Expand All @@ -216,6 +241,7 @@ def _on_download_finished(self, file_path):

def _show_install_confirmation(self, file_path):
self.progress_dialog.close()
self._downloader = None
dialog = InstallConfirmDialog(self.parent_widget)
if dialog.exec_() == QDialog.Accepted:
self._install_update(file_path)
Expand Down
33 changes: 33 additions & 0 deletions modules/auth/mvc/auth_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ def login_user(self, data: dict) -> None:
try:
auto_login = self.view.login_page.get_auto_login_state()
user_id = self.model.save_user(user_data=data, auto_login=auto_login)
if not isinstance(user_id, int):
NotificationService().show_toast(
"error",
"Ошибка входа",
"Не удалось сохранить сессию. Попробуйте войти снова."
)
return

self.view.login_page.clear_lineedits()
self.login_successful.emit("auth", user_id)
Expand All @@ -120,6 +127,13 @@ def signup_user(self, user_data: dict) -> None:
try:
auto_login = self.view.signup_page.get_auto_login_state()
user_id = self.model.save_user(user_data=user_data, auto_login=auto_login)
if not isinstance(user_id, int):
NotificationService().show_toast(
"error",
"Ошибка регистрации",
"Не удалось сохранить сессию. Попробуйте войти снова."
)
return

self.view.signup_page.clear_lineedits()

Expand Down Expand Up @@ -154,6 +168,13 @@ def signup(self, data: dict, email: str, password: str) -> None:
message=f"Код подтверждения отправлен на {email}"
)
code = EmailConfirmDialog.get_code(parent=self.auth_window)
if not code:
NotificationService().show_toast(
notification_type="info",
title="Подтверждение отменено",
message="Регистрация не завершена: код подтверждения не введён."
)
return

# Create success callback
success_cb = lambda data: self.signup_user(user_data=data)
Expand Down Expand Up @@ -228,6 +249,13 @@ def open_email_confirm_modal_window(self, data, email: str) -> None:
"""
logging.info(f"Reset password data received: {data}")
code = EmailConfirmDialog.get_code(parent=self.auth_window)
if not code:
NotificationService().show_toast(
notification_type="info",
title="Подтверждение отменено",
message="Сброс пароля не завершён: код подтверждения не введён."
)
return

# Create success callback
success_cb = lambda data: self.switch_to_reset_password_page(data=data)
Expand Down Expand Up @@ -328,10 +356,12 @@ def on_login_page_lineedits_changed(self) -> None:

# Validate email
if not self.field_validator.validate_email(email=email):
self.view.login_page.update_submit_button_state(state=False)
return

# Validate password
if not self.field_validator.validate_password(password=password):
self.view.login_page.update_submit_button_state(state=False)
return

# Defining login button state (True if both lineedits has text, else False)
Expand Down Expand Up @@ -400,10 +430,12 @@ def on_signup_page_lineedits_changed(self) -> None:

# Validate email
if not self.field_validator.validate_email(email=email):
self.view.signup_page.update_submit_button_state(state=False)
return

# Validate password
if not self.field_validator.validate_password(password=password):
self.view.signup_page.update_submit_button_state(state=False)
return

# Check password matching
Expand Down Expand Up @@ -491,6 +523,7 @@ def on_reset_password_page_lineedits_changed(self) -> None:

# Validate password
if not self.field_validator.validate_password(password=password):
self.view.forgot_page_reset_password.update_submit_button_state(state=False)
return

# Check password matching
Expand Down
3 changes: 3 additions & 0 deletions modules/auth/mvc/auth_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ def save_user(self, user_data: dict, auto_login: bool) -> int | None:
return None

user_id = user.get("id", None)
if not isinstance(user_id, int):
logging.error("Cannot save user session: invalid or missing user id.")
return None

# Save access and refresh tokens
self.save_token(token_name=f"access_token_{user_id}", token="access_token", data=user_data)
Expand Down
11 changes: 7 additions & 4 deletions modules/document_editor/mvc/document_editor_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ def _on_add_page_button_clicked(self) -> None:

def _on_duplicate_page_button_clicked(self) -> None:
"""Handles the duplicate page button click event."""
self.view.duplicate_selected_pages()
self._on_document_data_changed()
changed = self.view.duplicate_selected_pages()
if changed:
self._on_document_data_changed()
self._on_table_selection_changed()


def _on_print_button_clicked(self) -> None:
Expand Down Expand Up @@ -250,8 +252,9 @@ def _on_export_button_clicked(self) -> None:

def _on_delete_page_button_clicked(self) -> None:
"""Handles the delete page button click event."""
self.view.delete_selected_pages()
self._on_document_data_changed()
changed = self.view.delete_selected_pages()
if changed:
self._on_document_data_changed()
self._on_table_selection_changed()


Expand Down
13 changes: 8 additions & 5 deletions modules/document_editor/mvc/document_editor_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,12 @@ def save_document(self, data: dict) -> None:

def _get_user_token(self) -> str | None:
last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED)

if not last_logged:
if not isinstance(last_logged, dict):
return None

user_id = last_logged["user_id"]
user_id = last_logged.get("user_id")
if user_id in (None, ""):
return None

access_token = keyring.get_password(
service_name="Documents Exp",
Expand Down Expand Up @@ -222,10 +223,12 @@ def _refresh_tokens(self) -> bool:
"""Refreshes tokens using the stored refresh token."""
try:
last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED)
if not last_logged:
if not isinstance(last_logged, dict):
return False

user_id = last_logged.get("user_id")
if user_id in (None, ""):
return False
refresh_token = keyring.get_password("Documents Exp", f"refresh_token_{user_id}")

if not refresh_token:
Expand All @@ -237,4 +240,4 @@ def _refresh_tokens(self) -> bool:
return True
except Exception as e:
logging.error(f"Failed to refresh tokens: {e}")
return False
return False
40 changes: 29 additions & 11 deletions modules/document_editor/mvc/document_editor_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,12 @@ def add_new_page(self) -> None:
self.table_view.edit(index)


def delete_selected_pages(self) -> None:
"""Deletes selected or checked pages from the table."""
def delete_selected_pages(self) -> bool:
"""Deletes selected or checked pages from the table.

Returns:
bool: True if any page was deleted, otherwise False.
"""
model = self.table_view.model()
rows_to_delete = set()

Expand All @@ -313,10 +317,15 @@ def delete_selected_pages(self) -> None:
model.removeRow(row)

self.table_view.clearSelection()
return bool(rows_to_delete)


def duplicate_selected_pages(self) -> None:
"""Duplicates selected or checked pages."""
def duplicate_selected_pages(self) -> bool:
"""Duplicates selected or checked pages.

Returns:
bool: True if any page was duplicated, otherwise False.
"""
model = self.table_view.model()
rows_to_duplicate = set()

Expand Down Expand Up @@ -375,6 +384,7 @@ def duplicate_selected_pages(self) -> None:
model.index(insert_row, 0),
QItemSelectionModel.Select | QItemSelectionModel.Rows
)
return bool(rows_to_duplicate)


def has_selection_or_checks(self) -> bool:
Expand Down Expand Up @@ -561,14 +571,22 @@ def has_selected_pages(self) -> bool:
return self.pages_table.has_selection_or_checks()


def delete_selected_pages(self) -> None:
"""Deletes selected or checked pages."""
self.pages_table.delete_selected_pages()
def delete_selected_pages(self) -> bool:
"""Deletes selected or checked pages.

Returns:
bool: True if any page was deleted, otherwise False.
"""
return self.pages_table.delete_selected_pages()


def duplicate_selected_pages(self) -> None:
"""Duplicates selected or checked pages."""
self.pages_table.duplicate_selected_pages()
def duplicate_selected_pages(self) -> bool:
"""Duplicates selected or checked pages.

Returns:
bool: True if any page was duplicated, otherwise False.
"""
return self.pages_table.duplicate_selected_pages()


def get_document_data(self) -> dict:
Expand Down Expand Up @@ -811,4 +829,4 @@ def switch_to_files_tab(self) -> None:
self.ui.tabs_tabWidget.setCurrentIndex(1)

def set_file_drop_active(self, active: bool) -> None:
self.files_tab.file_drop_widget.setDragActive(active)
self.files_tab.file_drop_widget.setDragActive(active)
Loading
Loading