bouquin/bouquin/documents.py
2025-12-09 12:48:59 +11:00

601 lines
20 KiB
Python

from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QComboBox,
QLineEdit,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QPushButton,
QFileDialog,
QMessageBox,
QWidget,
QFrame,
QToolButton,
QListWidget,
QListWidgetItem,
QSizePolicy,
QStyle,
)
from .db import DBManager, DocumentRow
from .settings import load_db_config
from .time_log import TimeCodeManagerDialog
from . import strings
class TodaysDocumentsWidget(QFrame):
"""
Collapsible sidebar widget showing today's documents.
"""
def __init__(
self, db: DBManager, date_iso: str, parent: QWidget | None = None
) -> None:
super().__init__(parent)
self._db = db
self._current_date = date_iso
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + open-documents button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("todays_documents"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.open_btn = QToolButton()
self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.open_btn.setToolTip(strings._("project_documents_title"))
self.open_btn.setAutoRaise(True)
self.open_btn.clicked.connect(self._open_documents_dialog)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.open_btn)
# Body: list of today's documents
self.body = QWidget()
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 4, 0, 0)
body_layout.setSpacing(2)
self.list = QListWidget()
self.list.setSelectionMode(QAbstractItemView.SingleSelection)
self.list.setMaximumHeight(160)
self.list.itemDoubleClicked.connect(self._open_selected_document)
body_layout.addWidget(self.list)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# Initial fill
self.reload()
# ----- public API ---------------------------------------------------
def reload(self) -> None:
"""Refresh the list of today's documents."""
self.list.clear()
rows = self._db.todays_documents(self._current_date)
if not rows:
item = QListWidgetItem(strings._("todays_documents_none"))
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
self.list.addItem(item)
return
for doc_id, file_name, project_name in rows:
label = file_name
extra_parts = []
if project_name:
extra_parts.append(project_name)
if extra_parts:
label = f"{file_name} - " + " · ".join(extra_parts)
item = QListWidgetItem(label)
item.setData(
Qt.ItemDataRole.UserRole,
{"doc_id": doc_id, "file_name": file_name},
)
self.list.addItem(item)
# ----- internals ----------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
self.reload()
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
self.reload()
def _open_selected_document(self, item: QListWidgetItem) -> None:
data = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(data, dict):
return
doc_id = data.get("doc_id")
file_name = data.get("file_name") or ""
if doc_id is None or not file_name:
return
self._open_document(int(doc_id), file_name)
def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open a document from the list."""
from .document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _open_documents_dialog(self) -> None:
"""Open the full DocumentsDialog."""
dlg = DocumentsDialog(self._db, self, current_date=self._current_date)
dlg.exec()
# Refresh after any changes
self.reload()
class DocumentsDialog(QDialog):
"""
Per-project document manager.
- Choose a project
- See list of attached documents
- Add (from file), open (via temp file), delete
- Inline-edit description
- Inline-edit tags (comma-separated), using the global tags table
"""
FILE_COL = 0
TAGS_COL = 1
DESC_COL = 2
ADDED_COL = 3
SIZE_COL = 4
def __init__(
self,
db: DBManager,
parent: QWidget | None = None,
initial_project_id: Optional[int] = None,
current_date: Optional[str] = None,
) -> None:
super().__init__(parent)
self._db = db
self.cfg = load_db_config()
self._reloading_docs = False
self._search_text: str = ""
self._current_date = current_date # Store the current date for document uploads
self.setWindowTitle(strings._("project_documents_title"))
self.resize(900, 450)
root = QVBoxLayout(self)
# --- Project selector -------------------------------------------------
form = QFormLayout()
proj_row = QHBoxLayout()
self.project_combo = QComboBox()
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
self.manage_projects_btn.clicked.connect(self._manage_projects)
proj_row.addWidget(self.project_combo, 1)
proj_row.addWidget(self.manage_projects_btn)
form.addRow(strings._("project"), proj_row)
# --- Search box (all projects) ----------------------------------------
self.search_edit = QLineEdit()
self.search_edit.setClearButtonEnabled(True)
self.search_edit.setPlaceholderText(strings._("documents_search_placeholder"))
self.search_edit.textChanged.connect(self._on_search_text_changed)
form.addRow(strings._("documents_search_label"), self.search_edit)
root.addLayout(form)
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
# --- Table of documents ----------------------------------------------
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("documents_col_file"), # FILE_COL
strings._("documents_col_tags"), # TAGS_COL
strings._("documents_col_description"), # DESC_COL
strings._("documents_col_added"), # ADDED_COL
strings._("documents_col_size"), # SIZE_COL
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.FILE_COL, QHeaderView.Stretch)
header.setSectionResizeMode(self.TAGS_COL, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.DESC_COL, QHeaderView.Stretch)
header.setSectionResizeMode(self.ADDED_COL, QHeaderView.ResizeToContents)
header.setSectionResizeMode(self.SIZE_COL, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
# Editable: tags + description
self.table.setEditTriggers(
QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked
)
self.table.itemChanged.connect(self._on_item_changed)
self.table.itemDoubleClicked.connect(self._on_open_clicked)
root.addWidget(self.table, 1)
# --- Buttons ---------------------------------------------------------
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.add_btn = QPushButton(strings._("documents_add"))
self.add_btn.clicked.connect(self._on_add_clicked)
btn_row.addWidget(self.add_btn)
self.open_btn = QPushButton(strings._("documents_open"))
self.open_btn.clicked.connect(self._on_open_clicked)
btn_row.addWidget(self.open_btn)
self.delete_btn = QPushButton(strings._("documents_delete"))
self.delete_btn.clicked.connect(self._on_delete_clicked)
btn_row.addWidget(self.delete_btn)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
root.addLayout(btn_row)
# Separator at bottom (purely cosmetic)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
root.addWidget(line)
# Init data
self._reload_projects()
self._select_initial_project(initial_project_id)
self._reload_documents()
# --- Helpers -------------------------------------------------------------
def _reload_projects(self) -> None:
self.project_combo.blockSignals(True)
try:
self.project_combo.clear()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
finally:
self.project_combo.blockSignals(False)
def _select_initial_project(self, project_id: Optional[int]) -> None:
if project_id is None:
if self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
return
idx = self.project_combo.findData(project_id)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
elif self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
def _current_project(self) -> Optional[int]:
idx = self.project_combo.currentIndex()
if idx < 0:
return None
proj_id = self.project_combo.itemData(idx)
return int(proj_id) if proj_id is not None else None
def _manage_projects(self) -> None:
dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self)
dlg.exec()
self._reload_projects()
self._reload_documents()
def _on_search_text_changed(self, text: str) -> None:
"""Update the in-memory search text and reload the table."""
self._search_text = text
self._reload_documents()
def _reload_documents(self) -> None:
search = (self._search_text or "").strip()
self._reloading_docs = True
try:
self.table.setRowCount(0)
if search:
# Global search across all projects
rows: list[DocumentRow] = self._db.search_documents(search)
else:
proj_id = self._current_project()
if proj_id is None:
return
rows = self._db.documents_for_project(proj_id)
self.table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
(
doc_id,
_project_id,
project_name,
file_name,
description,
size_bytes,
uploaded_at,
) = r
# Col 0: File
file_item = QTableWidgetItem(file_name)
file_item.setData(Qt.ItemDataRole.UserRole, doc_id)
file_item.setFlags(file_item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row_idx, self.FILE_COL, file_item)
# Col 1: Tags (comma-separated)
tags = self._db.get_tags_for_document(doc_id)
tag_names = [name for (_tid, name, _color) in tags]
tags_text = ", ".join(tag_names)
tags_item = QTableWidgetItem(tags_text)
# If there is at least one tag, colour the cell using the first tag's colour
if tags:
first_color = tags[0][2]
if first_color:
col = QColor(first_color)
tags_item.setBackground(col)
# Choose a readable text color
if col.lightness() < 128:
tags_item.setForeground(QColor("#ffffff"))
else:
tags_item.setForeground(QColor("#000000"))
self.table.setItem(row_idx, self.TAGS_COL, tags_item)
if not self.cfg.tags:
self.table.hideColumn(self.TAGS_COL)
# Col 2: Description (editable)
desc_item = QTableWidgetItem(description or "")
self.table.setItem(row_idx, self.DESC_COL, desc_item)
# Col 3: Added at (editable)
added_label = uploaded_at
added_item = QTableWidgetItem(added_label)
self.table.setItem(row_idx, self.ADDED_COL, added_item)
# Col 4: Size (not editable)
size_item = QTableWidgetItem(self._format_size(size_bytes))
size_item.setFlags(size_item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(row_idx, self.SIZE_COL, size_item)
finally:
self._reloading_docs = False
# --- Signals -------------------------------------------------------------
def _on_project_changed(self, idx: int) -> None:
_ = idx
self._reload_documents()
def _on_add_clicked(self) -> None:
proj_id = self._current_project()
if proj_id is None:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_no_project_selected"),
)
return
paths, _ = QFileDialog.getOpenFileNames(
self,
strings._("documents_add"),
"",
strings._("documents_file_filter_all"),
)
if not paths:
return
for path in paths:
try:
self._db.add_document_from_path(
proj_id, path, uploaded_at=self._current_date
)
except Exception as e: # pragma: no cover
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_add_failed").format(error=str(e)),
)
self._reload_documents()
def _selected_doc_meta(self) -> tuple[Optional[int], Optional[str]]:
row = self.table.currentRow()
if row < 0:
return None, None
file_item = self.table.item(row, self.FILE_COL)
if file_item is None:
return None, None
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
file_name = file_item.text()
return (int(doc_id) if doc_id is not None else None, file_name)
def _on_open_clicked(self, *args) -> None:
doc_id, file_name = self._selected_doc_meta()
if doc_id is None or not file_name:
return
self._open_document(doc_id, file_name)
def _on_delete_clicked(self) -> None:
doc_id, _file_name = self._selected_doc_meta()
if doc_id is None:
return
resp = QMessageBox.question(
self,
strings._("project_documents_title"),
strings._("documents_confirm_delete"),
)
if resp != QMessageBox.StandardButton.Yes:
return
self._db.delete_document(doc_id)
self._reload_documents()
def _on_item_changed(self, item: QTableWidgetItem) -> None:
"""
Handle inline edits to Description, Tags, and Added date.
"""
if self._reloading_docs or item is None:
return
row = item.row()
col = item.column()
file_item = self.table.item(row, self.FILE_COL)
if file_item is None:
return
doc_id = file_item.data(Qt.ItemDataRole.UserRole)
if doc_id is None:
return
doc_id = int(doc_id)
# Description column
if col == self.DESC_COL:
desc = item.text().strip() or None
self._db.update_document_description(doc_id, desc)
return
# Tags column
if col == self.TAGS_COL:
raw = item.text()
# split on commas, strip, drop empties
names = [p.strip() for p in raw.split(",") if p.strip()]
self._db.set_tags_for_document(doc_id, names)
# Re-normalise text to the canonical tag names stored in DB
tags = self._db.get_tags_for_document(doc_id)
tag_names = [name for (_tid, name, _color) in tags]
tags_text = ", ".join(tag_names)
self._reloading_docs = True
try:
item.setText(tags_text)
# Reset / apply background based on first tag colour
if tags:
first_color = tags[0][2]
if first_color:
col = QColor(first_color)
item.setBackground(col)
if col.lightness() < 128:
item.setForeground(QColor("#ffffff"))
else:
item.setForeground(QColor("#000000"))
else:
# No tags: clear background / foreground to defaults
item.setBackground(QColor())
item.setForeground(QColor())
finally:
self._reloading_docs = False
return
# Added date column
if col == self.ADDED_COL:
date_str = item.text().strip()
# Validate date format (YYYY-MM-DD)
if not self._validate_date_format(date_str):
QMessageBox.warning(
self,
strings._("project_documents_title"),
(
strings._("documents_invalid_date_format")
if hasattr(strings, "_")
and callable(getattr(strings, "_"))
and "documents_invalid_date_format" in dir(strings)
else f"Invalid date format. Please use YYYY-MM-DD format.\nExample: {date_str[:4]}-01-15"
),
)
# Reload to reset the cell to its original value
self._reload_documents()
return
# Update the database
self._db.update_document_uploaded_at(doc_id, date_str)
return
# --- utils -------------------------------------------------------------
def _validate_date_format(self, date_str: str) -> bool:
"""
Validate that a date string is in YYYY-MM-DD format.
Returns True if valid, False otherwise.
"""
import re
from datetime import datetime
# Check basic format with regex
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
return False
# Validate it's a real date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return True
except ValueError:
return False
def _open_document(self, doc_id: int, file_name: str) -> None:
"""
Fetch BLOB from DB, write to a temporary file, and open with default app.
"""
from .document_utils import open_document_from_db
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
@staticmethod
def _format_size(size_bytes: int) -> str:
"""
Human-readable file size.
"""
if size_bytes < 1024:
return f"{size_bytes} B"
kb = size_bytes / 1024.0
if kb < 1024:
return f"{kb:.1f} KB"
mb = kb / 1024.0
if mb < 1024:
return f"{mb:.1f} MB"
gb = mb / 1024.0
return f"{gb:.1f} GB"