- Phase 9: Extend JiraConnectDialog with Project Knowledge config, mapping, and Sync Now button - Phase 10: Wire JiraSyncService to CanonicalAuditLogger for sync start/complete/fail events - Phase 11: Add retrieval regression suite with synthetic corpus and baseline metrics - Phase 14: Add comprehensive production guide (docs/jira-knowledge-guide.md) - Fix UI status label references (self.status -> self.conn_status) - Implement real sync trigger logic in UI using JiraSyncService
558 lines
24 KiB
Python
558 lines
24 KiB
Python
"""Connectors (MCP / REST API) management — the setup UI.
|
|
|
|
Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings).
|
|
Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a
|
|
header (icon + name + the software it covers) above a left-aligned row of
|
|
cards, one per real connector, each with its own on/off switch — user-added
|
|
connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get
|
|
✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under
|
|
"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own
|
|
setup dialog; OneDrive/SharePoint: neither, they only toggle).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox, QDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel,
|
|
QLineEdit, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
|
from ..core.worker import AgentWorker
|
|
from ..i18n import on_language_changed, tr
|
|
from ..state import AppContext
|
|
from .ext_connector_dialog import ExtConnectorEditDialog
|
|
from .icons import icon
|
|
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
|
|
|
|
|
|
class JiraConnectDialog(QDialog):
|
|
"""Jira connection and Project Knowledge configuration.
|
|
|
|
Extends the basic connection form with Project Knowledge settings:
|
|
enable/disable, project mapping, sync controls, and status display.
|
|
"""
|
|
|
|
def __init__(self, ctx: AppContext, parent=None):
|
|
"""Form khai báo kết nối Jira và cấu hình Project Knowledge."""
|
|
super().__init__(parent)
|
|
self.ctx = ctx
|
|
self.setWindowTitle(tr("connectors.jira_group"))
|
|
self.setMinimumWidth(520)
|
|
jira = ctx.config.data.get("jira", {})
|
|
jira_kb = ctx.config.data.get("jira_knowledge", {})
|
|
|
|
main_layout = QVBoxLayout(self)
|
|
|
|
# === Connection Section ===
|
|
conn_group = QGroupBox("Connection")
|
|
conn_form = QFormLayout(conn_group)
|
|
|
|
hint = QLabel(tr("connectors.jira_hint"))
|
|
hint.setObjectName("hint"); hint.setWordWrap(True); hint.setOpenExternalLinks(True)
|
|
conn_form.addRow(hint)
|
|
|
|
self.paste = QLineEdit()
|
|
self.paste.setPlaceholderText(tr("connectors.jira_paste_placeholder"))
|
|
self.paste.textChanged.connect(self._on_paste)
|
|
conn_form.addRow(tr("connectors.jira_paste"), self.paste)
|
|
|
|
self.url = QLineEdit(jira.get("base_url", ""))
|
|
self.url.setPlaceholderText("https://your-domain.atlassian.net")
|
|
self.email = QLineEdit(jira.get("email", ""))
|
|
self.token = QLineEdit(jira.get("api_token", ""))
|
|
self.token.setEchoMode(QLineEdit.Password)
|
|
|
|
conn_form.addRow(tr("connectors.jira_url"), self.url)
|
|
conn_form.addRow(tr("connectors.jira_email"), self.email)
|
|
conn_form.addRow(tr("connectors.jira_token"), self.token)
|
|
|
|
self.conn_status = QLabel()
|
|
self.conn_status.setObjectName("hint")
|
|
self.conn_status.setWordWrap(True)
|
|
conn_form.addRow(self.conn_status)
|
|
|
|
conn_row = QHBoxLayout()
|
|
self.test_btn = QPushButton(tr("connectors.jira_test"))
|
|
self.test_btn.clicked.connect(self._test)
|
|
conn_row.addWidget(self.test_btn)
|
|
conn_row.addStretch(1)
|
|
conn_group.setLayout(conn_form)
|
|
main_layout.addWidget(conn_group)
|
|
|
|
# === Project Knowledge Section ===
|
|
kb_group = QGroupBox("Project Knowledge")
|
|
kb_layout = QVBoxLayout(kb_group)
|
|
|
|
self.kb_enabled = QCheckBox("Enable Jira Project Knowledge")
|
|
self.kb_enabled.setChecked(jira_kb.get("enabled", False))
|
|
kb_layout.addWidget(self.kb_enabled)
|
|
|
|
kb_hint = QLabel("Map Cowork projects to Jira project keys. Format: cowork_project_id:JIRA_KEY")
|
|
kb_hint.setObjectName("hint")
|
|
kb_hint.setWordWrap(True)
|
|
kb_layout.addWidget(kb_hint)
|
|
|
|
# Project mapping input
|
|
mapping_form = QFormLayout()
|
|
self.project_mapping = QLineEdit()
|
|
# Load existing mappings
|
|
existing_projects = jira_kb.get("projects", {})
|
|
if existing_projects:
|
|
mapping_str = ", ".join(f"{k}:{v}" for k, v in existing_projects.items())
|
|
self.project_mapping.setText(mapping_str)
|
|
self.project_mapping.setPlaceholderText("proj-alpha:ALPHA, proj-beta:BETA")
|
|
mapping_form.addRow("Project Mapping", self.project_mapping)
|
|
kb_layout.addLayout(mapping_form)
|
|
|
|
# Sync controls
|
|
sync_row = QHBoxLayout()
|
|
self.sync_btn = QPushButton("Sync Now")
|
|
self.sync_btn.clicked.connect(self._trigger_sync)
|
|
self.sync_btn.setEnabled(False)
|
|
sync_row.addWidget(self.sync_btn)
|
|
|
|
self.sync_status = QLabel("Not configured")
|
|
self.sync_status.setObjectName("hint")
|
|
sync_row.addWidget(self.sync_status)
|
|
sync_row.addStretch(1)
|
|
kb_layout.addLayout(sync_row)
|
|
|
|
main_layout.addWidget(kb_group)
|
|
|
|
# === Save/Close Row ===
|
|
row = QHBoxLayout()
|
|
self.save_btn = QPushButton(tr("connectors.jira_save"))
|
|
self.save_btn.setObjectName("primary")
|
|
self.save_btn.setIcon(icon("save"))
|
|
self.save_btn.clicked.connect(self._save_close)
|
|
row.addStretch(1)
|
|
row.addWidget(self.save_btn)
|
|
rw = QWidget()
|
|
rw.setLayout(row)
|
|
main_layout.addWidget(rw)
|
|
|
|
# Update sync button state
|
|
self.kb_enabled.toggled.connect(self._update_sync_state)
|
|
self._update_sync_state(self.kb_enabled.isChecked())
|
|
|
|
def _update_sync_state(self, enabled: bool) -> None:
|
|
"""Enable/disable sync controls based on KB checkbox."""
|
|
self.sync_btn.setEnabled(enabled)
|
|
if not enabled:
|
|
self.sync_status.setText("Disabled")
|
|
|
|
def _trigger_sync(self) -> None:
|
|
"""Trigger a background sync job using JiraSyncService."""
|
|
self.sync_status.setText("Syncing...")
|
|
self.sync_btn.setEnabled(False)
|
|
|
|
def job(_w):
|
|
from ..application.jira_knowledge.sync_service import JiraSyncService
|
|
from ..application.jira_knowledge.target_resolver import JiraTargetResolver
|
|
from ..application.jira_knowledge.credential_resolver import JiraCredentialResolver
|
|
from ..infrastructure.secrets.keyring_adapter import KeyringAdapter
|
|
from ..mcp_servers.project_context.foundation import IdentityContext
|
|
|
|
# Resolve identity from config or use a default for the current project
|
|
# In a real multi-user app, this would come from the logged-in user session
|
|
jira_kb = self.ctx.config.data.get("jira_knowledge", {})
|
|
projects = jira_kb.get("projects", {})
|
|
if not projects:
|
|
return {"status": "error", "message": "No project mapping configured"}
|
|
|
|
# Use the first mapped project for this demo/trigger
|
|
# Ideally, the UI would let you select which project to sync
|
|
cowork_project_id = list(projects.keys())[0]
|
|
|
|
identity = IdentityContext(
|
|
actor_id="ui-user",
|
|
org_unit="local",
|
|
customer="internal",
|
|
project=cowork_project_id,
|
|
granted_scopes=frozenset({"read"})
|
|
)
|
|
|
|
service = JiraSyncService(
|
|
target_resolver=JiraTargetResolver(),
|
|
credential_resolver=JiraCredentialResolver(KeyringAdapter())
|
|
)
|
|
|
|
result = service.full_sync(identity)
|
|
return {
|
|
"status": "success",
|
|
"count": result.processed,
|
|
"failed": result.failed,
|
|
"duration": result.duration_seconds
|
|
}
|
|
|
|
def done(r):
|
|
self.sync_btn.setEnabled(True)
|
|
status = r.get("status", "unknown")
|
|
if status == "success":
|
|
count = r.get("count", 0)
|
|
failed = r.get("failed", 0)
|
|
duration = r.get("duration", 0)
|
|
msg = f"Success: {count} issues synced"
|
|
if failed > 0:
|
|
msg += f" ({failed} failed)"
|
|
msg += f" in {duration:.1f}s"
|
|
self.sync_status.setText(msg)
|
|
else:
|
|
self.sync_status.setText(f"Failed: {r.get('message', 'Unknown error')}")
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(done)
|
|
w.failed.connect(lambda e: (self.sync_btn.setEnabled(True),
|
|
self.sync_status.setText(f"Error: {str(e)[:100]}")))
|
|
self._sync_worker = w
|
|
w.start()
|
|
|
|
def _save(self) -> None:
|
|
"""Ghi thông tin Jira và Project Knowledge vào cấu hình."""
|
|
j = self.ctx.config.data.setdefault("jira", {})
|
|
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
|
|
"api_token": self.token.text().strip()})
|
|
j.setdefault("enabled", True)
|
|
|
|
# Save Jira Knowledge config
|
|
jira_kb = self.ctx.config.data.setdefault("jira_knowledge", {})
|
|
jira_kb["enabled"] = self.kb_enabled.isChecked()
|
|
|
|
# Parse project mapping
|
|
mapping_str = self.project_mapping.text().strip()
|
|
projects = {}
|
|
if mapping_str:
|
|
for pair in mapping_str.split(","):
|
|
if ":" in pair:
|
|
k, v = pair.split(":", 1)
|
|
projects[k.strip()] = v.strip()
|
|
jira_kb["projects"] = projects
|
|
|
|
self.ctx.save()
|
|
|
|
def _save_close(self) -> None:
|
|
"""Lưu rồi đóng hộp thoại."""
|
|
self._save()
|
|
self.accept()
|
|
|
|
def _test(self) -> None:
|
|
"""Thử kết nối bằng một truy vấn tối thiểu, chạy ở luồng nền."""
|
|
from ..core import jira_tool
|
|
self._save()
|
|
cfg = self.ctx.config.data.get("jira", {})
|
|
if not jira_tool.configured(cfg):
|
|
self.conn_status.setText(tr("connectors.jira_need_fields"))
|
|
return
|
|
self.conn_status.setText(tr("connectors.jira_testing"))
|
|
self.test_btn.setEnabled(False)
|
|
|
|
def job(_w):
|
|
"""Chạy nền: tìm đúng 1 issue mới nhất để xác nhận kết nối sống."""
|
|
return {"out": jira_tool.search(cfg, "order by created DESC", 1)}
|
|
|
|
def done(r):
|
|
"""Hiện kết quả thử: coi là lỗi khi thông điệp bắt đầu bằng câu báo chưa cấu
|
|
hình hoặc tìm kiếm thất bại.
|
|
"""
|
|
self.test_btn.setEnabled(True)
|
|
out = r.get("out", "")
|
|
ok = not out.lower().startswith(("jira is not configured", "jira search failed"))
|
|
self.conn_status.setText(tr("connectors.jira_ok") if ok
|
|
else tr("connectors.jira_fail", err=out[:200]))
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(done)
|
|
w.failed.connect(lambda e: (self.test_btn.setEnabled(True),
|
|
self.conn_status.setText(tr("connectors.jira_fail", err=str(e)[:200]))))
|
|
self._jira_worker = w
|
|
w.start()
|
|
|
|
|
|
class ConnectorsPanel(QWidget):
|
|
"""Bảng Connectors trong Cài đặt: MS365 dựng sẵn, Jira, và connector MCP tự thêm,
|
|
gom theo bốn nhóm CAD / CAE / MS365 / Khác.
|
|
|
|
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
|
|
"""
|
|
_EXT_CATEGORY_LABELS = {
|
|
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
|
|
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
|
|
"ms365": "MS365 (Microsoft 365 / OneDrive / SharePoint)",
|
|
"other": "Other (any generic MCP server)",
|
|
}
|
|
_EXT_CATEGORY_ICONS = {"cad": "wrench", "cae": "ruler", "ms365": "cloud", "other": "plug"}
|
|
# TEMPORARY: only OneDrive + SharePoint (auto-connect via locally-synced
|
|
# OneDrive folders, no sign-in). Restore Outlook/Teams/Meeting when cloud
|
|
# OAuth is re-enabled.
|
|
_MS365_BUILTIN_LABELS = {"onedrive": "OneDrive", "sharepoint": "SharePoint"}
|
|
|
|
def __init__(self, ctx: AppContext):
|
|
"""Panel quản lý connector ngoài.
|
|
|
|
Công tắc tổng ở trên cùng: tắt là agent KHÔNG nối tới connector nào, bất kể
|
|
từng connector bên dưới có bật hay không.
|
|
"""
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
lay = QVBoxLayout(self)
|
|
|
|
# Master switch: connect to external connectors at all (default ON).
|
|
# Off = the agent connects to NO external connector/MCP (see
|
|
# AppContext.build_mcp_tools), regardless of the per-connector checks below.
|
|
self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external"))
|
|
self.connect_external_sw.setChecked(self.ctx.config.connect_external)
|
|
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
|
|
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
|
|
lay.addWidget(self.connect_external_sw)
|
|
|
|
hint = QLabel(tr("settings.ext_hint"))
|
|
hint.setObjectName("hint")
|
|
hint.setWordWrap(True)
|
|
lay.addWidget(hint)
|
|
|
|
# Each category (CAD/CAE/MS365/Other) is a header + a left-aligned,
|
|
# wrapping row of cards — one per real connector — instead of a tree
|
|
# the admin had to expand to see what was inside.
|
|
self._cat_scroll = QScrollArea()
|
|
self._cat_scroll.setWidgetResizable(True)
|
|
self._cat_scroll.setFrameShape(QScrollArea.NoFrame)
|
|
cat_host = QWidget()
|
|
enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout
|
|
self._cat_lay = QVBoxLayout(cat_host)
|
|
self._cat_lay.setContentsMargins(0, 4, 0, 4)
|
|
self._cat_lay.setSpacing(10)
|
|
self._cat_scroll.setWidget(cat_host)
|
|
lay.addWidget(self._cat_scroll, 1)
|
|
|
|
self.add_btn = QPushButton(tr("settings.ext_add_btn"))
|
|
self.add_btn.setIcon(icon("plus"))
|
|
self.add_btn.setObjectName("primary")
|
|
self.add_btn.setCursor(Qt.PointingHandCursor)
|
|
self.add_btn.clicked.connect(self._ext_add)
|
|
add_row = QHBoxLayout()
|
|
add_row.addWidget(self.add_btn)
|
|
add_row.addStretch(1)
|
|
lay.addLayout(add_row)
|
|
|
|
self.ms365_local_status = QLabel()
|
|
self.ms365_local_status.setObjectName("hint")
|
|
self.ms365_local_status.setWordWrap(True)
|
|
lay.addWidget(self.ms365_local_status)
|
|
|
|
# on_language_changed() below already invokes _retranslate() once
|
|
# immediately (see i18n.py), which itself calls _reload_connectors() —
|
|
# calling it again here would rebuild the category cards twice
|
|
# back-to-back with no event-loop turn in between, so the first pass's
|
|
# widgets are only QUEUED for deleteLater() (not yet gone) when the
|
|
# second pass adds new ones on top: the two rows visually overlap
|
|
# (the exact bug agents_admin_tab.py hit the same way).
|
|
self._refresh_ms365_local_status()
|
|
on_language_changed(self._retranslate)
|
|
|
|
# ---- rendering ------------------------------------------------------------
|
|
def _clear_categories(self) -> None:
|
|
"""Xoá sạch các mục nhóm trước khi dựng lại."""
|
|
while self._cat_lay.count():
|
|
item = self._cat_lay.takeAt(0)
|
|
w = item.widget()
|
|
if w is not None:
|
|
w.deleteLater()
|
|
|
|
def _reload_connectors(self) -> None:
|
|
"""Dựng lại toàn bộ bảng từ cấu hình mới nhất."""
|
|
self._clear_categories()
|
|
for cat in EXT_CATEGORIES:
|
|
self._cat_lay.addWidget(self._category_section(cat))
|
|
|
|
def _category_section(self, cat: str) -> QWidget:
|
|
"""Dựng một mục nhóm kèm lưới thẻ connector bên trong."""
|
|
section = QWidget()
|
|
enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout
|
|
sl = QVBoxLayout(section)
|
|
sl.setContentsMargins(0, 0, 0, 0)
|
|
sl.setSpacing(6)
|
|
|
|
hdr = QHBoxLayout()
|
|
hdr.setSpacing(6)
|
|
icon_lbl = QLabel()
|
|
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
|
|
hdr.addWidget(icon_lbl)
|
|
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
|
|
name_lbl = QLabel(name)
|
|
name_lbl.setStyleSheet("font-weight:700;")
|
|
hdr.addWidget(name_lbl)
|
|
if subtitle:
|
|
sub_lbl = QLabel("(" + subtitle)
|
|
sub_lbl.setObjectName("hint")
|
|
hdr.addWidget(sub_lbl)
|
|
hdr.addStretch(1)
|
|
sl.addLayout(hdr)
|
|
|
|
flow_host = QWidget()
|
|
flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10)
|
|
|
|
if cat == "ms365":
|
|
conns = self.ctx.config.ms365.get("connectors", {})
|
|
for key, label in self._MS365_BUILTIN_LABELS.items():
|
|
flow.addWidget(self._connector_card(
|
|
label, tr("connectors.builtin_auto"), bool(conns.get(key)),
|
|
lambda on, k=key: self._toggle_ms365_builtin(k, on)))
|
|
|
|
for entry in self.ctx.config.ext_connectors.get(cat, []):
|
|
mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
|
|
flow.addWidget(self._connector_card(
|
|
entry.get("name", ""), mode_label, bool(entry.get("enabled")),
|
|
lambda on, e=entry: self._toggle_ext_entry(e, on),
|
|
# QPushButton.clicked emits a `checked` bool — a lambda whose
|
|
# ONLY parameter is a defaulted capture (`e=entry`) looks like
|
|
# it accepts that bool, so Qt hands it the click state instead
|
|
# of using the default, silently replacing the captured dict
|
|
# with False. An explicit leading `checked=False` soaks up the
|
|
# signal's argument so `e` keeps the entry it was defined with.
|
|
edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e),
|
|
delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e)))
|
|
|
|
if cat == "other":
|
|
# Jira is a built-in "Other" connector (like OneDrive under MS365):
|
|
# switch = enabled; ✎ Sửa opens its own minimal setup dialog — no
|
|
# 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete).
|
|
jira = self.ctx.config.data.get("jira", {})
|
|
configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token"))
|
|
jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
|
|
flow.addWidget(self._connector_card(
|
|
"Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}",
|
|
configured and jira.get("enabled", True), self._toggle_jira,
|
|
edit_cb=self._open_jira_dialog))
|
|
|
|
sl.addWidget(flow_host)
|
|
return section
|
|
|
|
def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle,
|
|
edit_cb=None, delete_cb=None) -> QWidget:
|
|
"""Dựng một thẻ connector: tiêu đề, dòng phụ, công tắc bật/tắt và nút sửa/xoá
|
|
(nếu có).
|
|
"""
|
|
card = QFrame()
|
|
card.setFrameShape(QFrame.NoFrame)
|
|
style_card(card)
|
|
lay = QVBoxLayout(card)
|
|
lay.setContentsMargins(10, 8, 10, 8)
|
|
lay.setSpacing(4)
|
|
|
|
hdr = QHBoxLayout()
|
|
name_lbl = QLabel(title)
|
|
name_lbl.setStyleSheet("font-weight:700; border: none;")
|
|
hdr.addWidget(name_lbl)
|
|
hdr.addStretch(1)
|
|
sw = ToggleSwitch()
|
|
sw.setChecked(checked)
|
|
sw.toggled.connect(on_toggle)
|
|
hdr.addWidget(sw)
|
|
lay.addLayout(hdr)
|
|
|
|
sub_lbl = QLabel(subtitle)
|
|
sub_lbl.setObjectName("hint")
|
|
sub_lbl.setStyleSheet("border: none;")
|
|
lay.addWidget(sub_lbl)
|
|
|
|
if edit_cb is not None or delete_cb is not None:
|
|
actions = QHBoxLayout()
|
|
actions.setContentsMargins(0, 2, 0, 0)
|
|
actions.setSpacing(2)
|
|
if edit_cb is not None:
|
|
b = QPushButton(tr("settings.ext_edit_btn"))
|
|
b.setIcon(icon("edit"))
|
|
b.setFlat(True)
|
|
b.setCursor(Qt.PointingHandCursor)
|
|
b.clicked.connect(edit_cb)
|
|
actions.addWidget(b)
|
|
if delete_cb is not None:
|
|
b = QPushButton(tr("settings.ext_delete_btn"))
|
|
b.setIcon(icon("trash"))
|
|
b.setFlat(True)
|
|
b.setCursor(Qt.PointingHandCursor)
|
|
b.clicked.connect(delete_cb)
|
|
actions.addWidget(b)
|
|
actions.addStretch(1)
|
|
lay.addLayout(actions)
|
|
|
|
return card
|
|
|
|
def _toggle_ms365_builtin(self, key: str, checked: bool) -> None:
|
|
"""Bật/tắt một connector MS365 dựng sẵn và lưu ngay."""
|
|
self.ctx.config.ms365.setdefault("connectors", {})[key] = checked
|
|
self.ctx.save()
|
|
|
|
def _toggle_jira(self, checked: bool) -> None:
|
|
"""Bật/tắt connector Jira và lưu ngay."""
|
|
self.ctx.config.data.setdefault("jira", {})["enabled"] = checked
|
|
self.ctx.save()
|
|
|
|
def _toggle_ext_entry(self, entry: dict, checked: bool) -> None:
|
|
"""Bật/tắt một connector MCP tự thêm và lưu ngay."""
|
|
entry["enabled"] = checked
|
|
self.ctx.save()
|
|
|
|
# ---- CRUD -----------------------------------------------------------------
|
|
def _ext_add(self) -> None:
|
|
"""Thêm một connector MCP mới qua hộp thoại."""
|
|
dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0])
|
|
if dlg.exec():
|
|
entry = dlg.result_connector()
|
|
self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry)
|
|
self.ctx.save()
|
|
self._reload_connectors()
|
|
|
|
def _edit_ext_entry(self, cat: str, entry: dict) -> None:
|
|
"""Sửa một connector MCP đã có."""
|
|
dlg = ExtConnectorEditDialog(self, category=cat, connector=entry)
|
|
if dlg.exec():
|
|
entry.update(dlg.result_connector())
|
|
self.ctx.save()
|
|
self._reload_connectors()
|
|
|
|
def _open_jira_dialog(self) -> None:
|
|
"""Mở hộp thoại cấu hình Jira rồi dựng lại bảng."""
|
|
JiraConnectDialog(self.ctx, self).exec()
|
|
self._reload_connectors()
|
|
|
|
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
|
|
"""Xoá một connector MCP sau khi hỏi xác nhận."""
|
|
if QMessageBox.question(
|
|
self, tr("settings.ext_delete_btn"),
|
|
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
|
|
return
|
|
self.ctx.config.ext_connectors[cat].remove(entry)
|
|
self.ctx.save()
|
|
self._reload_connectors()
|
|
|
|
def _refresh_ms365_local_status(self) -> None:
|
|
"""Cập nhật dòng trạng thái MS365 cục bộ theo việc có tìm thấy thư mục OneDrive hay không."""
|
|
from .. import paths
|
|
root = paths.primary_onedrive_root()
|
|
if root is not None:
|
|
self.ms365_local_status.setText(tr("settings.ms365_local_connected", path=str(root)))
|
|
else:
|
|
self.ms365_local_status.setText(tr("settings.ms365_local_none"))
|
|
|
|
def _on_connect_external_toggled(self, on: bool) -> None:
|
|
"""Bật/tắt công tắc tổng cho connector ngoài, và khoá/mở cả bảng theo đó."""
|
|
self.ctx.config.set_connect_external(on)
|
|
self._apply_connect_external_enabled(on)
|
|
|
|
def _apply_connect_external_enabled(self, on: bool) -> None:
|
|
"""Grey out the per-connector setup when the master switch is off — the
|
|
agent won't connect to any of them anyway."""
|
|
for w in (self._cat_scroll, self.add_btn):
|
|
w.setEnabled(on)
|
|
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
|
self.connect_external_sw.setText(tr("connectors.connect_external"))
|
|
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
|
|
self.add_btn.setText(tr("settings.ext_add_btn"))
|
|
self._refresh_ms365_local_status()
|
|
self._reload_connectors()
|
|
self._apply_connect_external_enabled(self.ctx.config.connect_external)
|