diff --git a/src/cmdforge/gui/dialogs/review_dialog.py b/src/cmdforge/gui/dialogs/review_dialog.py new file mode 100644 index 0000000..3de5f6d --- /dev/null +++ b/src/cmdforge/gui/dialogs/review_dialog.py @@ -0,0 +1,206 @@ +"""Review dialog for rating registry tools.""" + +from typing import Optional, Dict + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit, QPlainTextEdit, QMessageBox +) +from PySide6.QtCore import Qt + + +class ReviewDialog(QDialog): + """Dialog for submitting or editing a tool review.""" + + # Set when user clicks Delete - caller checks this after reject() + delete_requested = False + + def __init__( + self, + parent, + owner: str, + name: str, + existing_review: Optional[Dict] = None + ): + super().__init__(parent) + self._owner = owner + self._name = name + self._existing = existing_review + self._rating = existing_review["rating"] if existing_review else 0 + self.delete_requested = False + self._setup_ui() + + def _setup_ui(self): + editing = self._existing is not None + self.setWindowTitle(f"{'Edit Review' if editing else 'Rate Tool'} - {self._owner}/{self._name}") + self.setMinimumSize(420, 380) + + layout = QVBoxLayout(self) + layout.setSpacing(14) + + # Title + title = QLabel(f"{'Edit your review' if editing else 'Rate'} {self._owner}/{self._name}") + title.setStyleSheet("font-size: 15px; font-weight: 600;") + layout.addWidget(title) + + # Star selector + star_label = QLabel("Rating:") + star_label.setStyleSheet("font-weight: 500;") + layout.addWidget(star_label) + + star_row = QHBoxLayout() + star_row.setSpacing(4) + self._star_buttons = [] + for i in range(1, 6): + btn = QPushButton("☆") + btn.setFixedSize(36, 36) + btn.setStyleSheet(""" + QPushButton { + font-size: 20px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f7fafc; + } + QPushButton:hover { + background: #edf2f7; + } + """) + btn.setCursor(Qt.PointingHandCursor) + btn.clicked.connect(lambda checked, n=i: self._set_rating(n)) + self._star_buttons.append(btn) + star_row.addWidget(btn) + star_row.addStretch() + layout.addLayout(star_row) + + # Pre-fill rating + if self._rating: + self._set_rating(self._rating) + + # Title input + title_label = QLabel("Title (optional):") + title_label.setStyleSheet("font-weight: 500;") + layout.addWidget(title_label) + + self.title_input = QLineEdit() + self.title_input.setMaxLength(100) + self.title_input.setPlaceholderText("Brief summary of your review") + self.title_input.setMinimumHeight(28) + if self._existing and self._existing.get("title"): + self.title_input.setText(self._existing["title"]) + layout.addWidget(self.title_input) + + # Content input + content_label = QLabel("Review (optional, min 10 chars if provided):") + content_label.setStyleSheet("font-weight: 500;") + layout.addWidget(content_label) + + self.content_input = QPlainTextEdit() + self.content_input.setPlaceholderText("Share your experience with this tool...") + self.content_input.setMinimumHeight(100) + self.content_input.setMaximumHeight(180) + if self._existing and self._existing.get("content"): + self.content_input.setPlainText(self._existing["content"]) + layout.addWidget(self.content_input) + + # Character count + self._char_count = QLabel("0 / 2000") + self._char_count.setStyleSheet("color: #718096; font-size: 11px;") + self._char_count.setAlignment(Qt.AlignRight) + self.content_input.textChanged.connect(self._update_char_count) + self._update_char_count() + layout.addWidget(self._char_count) + + layout.addStretch() + + # Buttons + btn_layout = QHBoxLayout() + + if editing: + self.btn_delete = QPushButton("Delete Review") + self.btn_delete.setObjectName("danger") + self.btn_delete.clicked.connect(self._on_delete) + btn_layout.addWidget(self.btn_delete) + + btn_layout.addStretch() + + btn_cancel = QPushButton("Cancel") + btn_cancel.setObjectName("secondary") + btn_cancel.clicked.connect(self.reject) + btn_layout.addWidget(btn_cancel) + + submit_text = "Update" if editing else "Submit" + self.btn_submit = QPushButton(submit_text) + self.btn_submit.clicked.connect(self._on_submit) + btn_layout.addWidget(self.btn_submit) + + layout.addLayout(btn_layout) + + def _set_rating(self, n: int): + self._rating = n + for i, btn in enumerate(self._star_buttons): + if i < n: + btn.setText("★") + btn.setStyleSheet(""" + QPushButton { + font-size: 20px; + border: 1px solid #d69e2e; + border-radius: 4px; + background: #fefcbf; + color: #d69e2e; + } + QPushButton:hover { background: #fef3c7; } + """) + else: + btn.setText("☆") + btn.setStyleSheet(""" + QPushButton { + font-size: 20px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f7fafc; + } + QPushButton:hover { background: #edf2f7; } + """) + + def _update_char_count(self): + count = len(self.content_input.toPlainText()) + self._char_count.setText(f"{count} / 2000") + if count > 2000: + self._char_count.setStyleSheet("color: #e53e3e; font-size: 11px;") + else: + self._char_count.setStyleSheet("color: #718096; font-size: 11px;") + + def _on_submit(self): + if self._rating < 1: + QMessageBox.warning(self, "Validation", "Please select a rating (1-5 stars).") + return + + content = self.content_input.toPlainText().strip() + if content and len(content) < 10: + QMessageBox.warning(self, "Validation", "Review content must be at least 10 characters.") + return + + if len(content) > 2000: + QMessageBox.warning(self, "Validation", "Review content must be 2000 characters or less.") + return + + self.accept() + + def _on_delete(self): + reply = QMessageBox.question( + self, "Delete Review", + "Are you sure you want to delete your review?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.Yes: + self.delete_requested = True + self.reject() + + def get_review_data(self) -> Dict: + """Get the review data entered by the user.""" + return { + "rating": self._rating, + "title": self.title_input.text().strip(), + "content": self.content_input.toPlainText().strip()[:2000], + } diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py index daf4685..4d9738e 100644 --- a/src/cmdforge/gui/pages/tools_page.py +++ b/src/cmdforge/gui/pages/tools_page.py @@ -2,7 +2,7 @@ from collections import defaultdict from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, Dict, Any import yaml @@ -154,6 +154,138 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]: return ("local", None) +def get_tool_registry_info(tool_name: str) -> Optional[Tuple[str, str]]: + """ + Get registry owner/name for a tool if it's registry-sourced. + + Returns: + (owner, name) tuple if tool is from registry, None if local-only. + """ + tools_dir = get_tools_dir() + config_path = tools_dir / tool_name / "config.yaml" + if not config_path.exists(): + return None + + try: + config_data = yaml.safe_load(config_path.read_text()) or {} + if not config_data.get("registry_hash"): + return None + + # Check if stored under owner subdir: ~/.cmdforge/// + if "/" in tool_name: + owner, name = tool_name.split("/", 1) + return (owner, name) + + # Flat dir: check source.author or installed_from in config + source = config_data.get("source", {}) + if isinstance(source, dict) and source.get("author"): + return (source["author"], tool_name) + if config_data.get("installed_from"): + parts = config_data["installed_from"].split("/", 1) + if len(parts) == 2: + return (parts[0], parts[1]) + + return None + except Exception: + return None + + +class RatingWorker(QThread): + """Background worker to fetch rating summary and user's review for a tool.""" + finished = Signal(str, dict) # tool_name, result dict + error = Signal(str, str) # tool_name, error message + + def __init__(self, tool_name: str, owner: str, name: str): + super().__init__() + self.tool_name = tool_name + self.owner = owner + self.name = name + + def run(self): + from ...registry_client import RegistryClient, RegistryError + try: + config = load_config() + client = RegistryClient() + client.token = config.registry.token + + result: Dict[str, Any] = {"rating": None, "my_review": None} + + try: + result["rating"] = client.get_tool_rating(self.owner, self.name) + except RegistryError: + pass + + if client.token: + try: + result["my_review"] = client.get_my_review(self.owner, self.name) + except RegistryError: + pass + + self.finished.emit(self.tool_name, result) + except Exception as e: + self.error.emit(self.tool_name, str(e)) + + +class SubmitReviewWorker(QThread): + """Background worker to submit or update a review.""" + finished = Signal(str) # success message + error = Signal(str) # error message + + def __init__(self, owner: str, name: str, rating: int, title: str, content: str, + review_id: Optional[int] = None): + super().__init__() + self.owner = owner + self.name = name + self.rating = rating + self.title = title + self.content = content + self.review_id = review_id # None = new, int = update + + def run(self): + from ...registry_client import RegistryClient, RegistryError, RateLimitError + try: + config = load_config() + client = RegistryClient() + client.token = config.registry.token + + if self.review_id is not None: + client.update_review(self.review_id, self.rating, self.title, self.content) + self.finished.emit("Review updated successfully") + else: + client.submit_review(self.owner, self.name, self.rating, self.title, self.content) + self.finished.emit("Review submitted successfully") + except RateLimitError as e: + minutes = max(1, e.retry_after // 60) + self.error.emit(f"Too many reviews. Try again in {minutes} minute(s).") + except RegistryError as e: + self.error.emit(e.message) + except Exception as e: + self.error.emit(str(e)) + + +class DeleteReviewWorker(QThread): + """Background worker to delete a review.""" + finished = Signal(str) + error = Signal(str) + + def __init__(self, review_id: int): + super().__init__() + self.review_id = review_id + + def run(self): + from ...registry_client import RegistryClient, RegistryError + try: + config = load_config() + client = RegistryClient() + client.token = config.registry.token + client.delete_review(self.review_id) + self.finished.emit("Review deleted successfully") + except RegistryError as e: + self.error.emit(e.message) + except Exception as e: + self.error.emit(str(e)) + + class ToolsPage(QWidget): """Main tools management page.""" @@ -165,6 +297,11 @@ class ToolsPage(QWidget): self._syncing = False # Prevent re-sync during update self._poll_timer = None # Timer for automatic status polling self._has_pending_tools = False # Track if we need to poll + self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review} + self._rating_worker = None + self._review_worker = None + self._my_slug: Optional[str] = None # Cached current user slug + self._my_slug_fetched = False self._setup_ui() self.refresh() @@ -264,6 +401,13 @@ class ToolsPage(QWidget): self.btn_delete.setEnabled(False) btn_layout.addWidget(self.btn_delete) + self.btn_rate = QPushButton("Rate Tool") + self.btn_rate.setObjectName("secondary") + self.btn_rate.clicked.connect(self._rate_tool) + self.btn_rate.setEnabled(False) + self.btn_rate.setToolTip("Local tools can't be rated") + btn_layout.addWidget(self.btn_rate) + btn_layout.addStretch() # Connect/Publish button @@ -498,7 +642,8 @@ class ToolsPage(QWidget): tool = load_tool(tool_name) if tool: self._current_tool = tool - self._show_tool_info(tool) + self._show_tool_info(tool, tool_name) + self._fetch_rating_if_needed(tool_name) self._update_buttons() def _get_qualified_name(self) -> Optional[str]: @@ -525,8 +670,15 @@ class ToolsPage(QWidget): pass return None - def _show_tool_info(self, tool: Tool): - """Display tool information.""" + def _show_tool_info(self, tool: Tool, qualified_name: Optional[str] = None): + """Display tool information. + + Args: + tool: The Tool object. + qualified_name: The qualified name (e.g. 'official/foo') used for + config path lookups. Falls back to tool.name if not provided. + """ + qname = qualified_name or tool.name lines = [] # Name and description @@ -535,7 +687,7 @@ class ToolsPage(QWidget): lines.append(f"

{tool.description}

") # Publish state - state, registry_hash = get_tool_publish_state(tool.name) + state, registry_hash = get_tool_publish_state(qname) if state == "published": lines.append( "

" + f"{stars_html} " + f"{avg:.1f}/5 from {count} review{'s' if count != 1 else ''}

" + ) + else: + lines.append( + "

" + "No ratings yet

" + ) + if my_review: + my_stars = "★" * my_review["rating"] + "☆" * (5 - my_review["rating"]) + lines.append( + f"

" + f"Your rating: {my_stars}

" + ) + elif registry_info: + lines.append( + "

" + "Loading ratings...

" + ) + # Source info if tool.source: source_type = tool.source.type @@ -654,6 +843,9 @@ class ToolsPage(QWidget): # Not connected - Connect button is always enabled self.btn_publish.setEnabled(True) + # Rate button state + self._update_rate_button() + def _create_tool(self): """Create a new tool.""" self.main_window.open_tool_builder() @@ -734,12 +926,196 @@ class ToolsPage(QWidget): if result: self.main_window.show_status(f"Published '{tool_name}'") + def _update_rate_button(self): + """Update the Rate button state based on current selection.""" + if not self._current_tool: + self.btn_rate.setEnabled(False) + self.btn_rate.setText("Rate Tool") + self.btn_rate.setToolTip("Select a tool to rate") + return + + tool_name = self._get_qualified_name() + if not tool_name: + self.btn_rate.setEnabled(False) + self.btn_rate.setText("Rate Tool") + self.btn_rate.setToolTip("Select a tool to rate") + return + + registry_info = get_tool_registry_info(tool_name) + + if not registry_info: + self.btn_rate.setEnabled(False) + self.btn_rate.setText("Rate Tool") + self.btn_rate.setToolTip("Local tools can't be rated") + return + + config = load_config() + if not config.registry.token: + self.btn_rate.setEnabled(False) + self.btn_rate.setText("Rate Tool") + self.btn_rate.setToolTip("Sign in to rate") + return + + owner, name = registry_info + + # Check if this is the user's own tool + if self._my_slug and owner == self._my_slug: + self.btn_rate.setEnabled(False) + self.btn_rate.setText("Rate Tool") + self.btn_rate.setToolTip("You can't rate your own tool") + return + + # Check if user has an existing review + cached = self._rating_cache.get(tool_name) + if cached and cached.get("my_review"): + self.btn_rate.setText("Edit Rating") + else: + self.btn_rate.setText("Rate Tool") + + self.btn_rate.setEnabled(True) + self.btn_rate.setToolTip("") + + def _fetch_my_slug(self): + """Fetch and cache the current user's slug.""" + if self._my_slug_fetched: + return + self._my_slug_fetched = True + + config = load_config() + if not config.registry.token: + return + + try: + from ...registry_client import RegistryClient, RegistryError + client = RegistryClient() + client.token = config.registry.token + me = client.get_me() + self._my_slug = me.get("slug") + except Exception: + pass + + def _fetch_rating_if_needed(self, tool_name: str): + """Fetch rating data for a registry tool if not cached.""" + registry_info = get_tool_registry_info(tool_name) + if not registry_info: + return + + # Ensure we have the user slug (non-blocking first time; runs in main thread once) + if not self._my_slug_fetched: + self._fetch_my_slug() + + if tool_name in self._rating_cache: + # Already cached - just update buttons + self._update_rate_button() + return + + owner, name = registry_info + + # Stop any existing rating worker + if self._rating_worker and self._rating_worker.isRunning(): + self._rating_worker.wait(500) + + self._rating_worker = RatingWorker(tool_name, owner, name) + self._rating_worker.finished.connect(self._on_rating_fetched) + self._rating_worker.error.connect(self._on_rating_error) + self._rating_worker.start() + + def _on_rating_fetched(self, tool_name: str, result: Dict): + """Handle rating data fetched from registry.""" + self._rating_cache[tool_name] = result + + # If this is still the selected tool, refresh its display + qualified = self._get_qualified_name() + current_name = qualified or (self._current_tool.name if self._current_tool else None) + if current_name == tool_name and self._current_tool: + self._show_tool_info(self._current_tool, tool_name) + self._update_rate_button() + + def _on_rating_error(self, tool_name: str, error: str): + """Handle rating fetch error (non-critical).""" + pass + + def _rate_tool(self): + """Open the review dialog for the selected tool.""" + if not self._current_tool: + return + + tool_name = self._get_qualified_name() or self._current_tool.name + registry_info = get_tool_registry_info(tool_name) + if not registry_info: + return + + owner, name = registry_info + cached = self._rating_cache.get(tool_name, {}) + existing_review = cached.get("my_review") + + from ..dialogs.review_dialog import ReviewDialog + dialog = ReviewDialog(self, owner, name, existing_review) + result = dialog.exec() + + if dialog.delete_requested and existing_review: + # User wants to delete their review + self._delete_review(tool_name, existing_review["id"]) + elif result: + # User submitted/updated + data = dialog.get_review_data() + review_id = existing_review["id"] if existing_review else None + self._submit_review(tool_name, owner, name, data, review_id) + + def _submit_review(self, tool_name: str, owner: str, name: str, + data: Dict, review_id: Optional[int]): + """Submit or update a review in background.""" + if self._review_worker and self._review_worker.isRunning(): + self._review_worker.wait(1000) + + self._review_worker = SubmitReviewWorker( + owner, name, data["rating"], data["title"], data["content"], + review_id=review_id + ) + self._review_worker.finished.connect( + lambda msg: self._on_review_action_done(tool_name, msg) + ) + self._review_worker.error.connect( + lambda err: self._on_review_action_error(err) + ) + self._review_worker.start() + + def _delete_review(self, tool_name: str, review_id: int): + """Delete a review in background.""" + if self._review_worker and self._review_worker.isRunning(): + self._review_worker.wait(1000) + + self._review_worker = DeleteReviewWorker(review_id) + self._review_worker.finished.connect( + lambda msg: self._on_review_action_done(tool_name, msg) + ) + self._review_worker.error.connect( + lambda err: self._on_review_action_error(err) + ) + self._review_worker.start() + + def _on_review_action_done(self, tool_name: str, message: str): + """Handle review submit/update/delete success.""" + self.main_window.show_status(message) + + # Invalidate cache and re-fetch + self._rating_cache.pop(tool_name, None) + + qualified = self._get_qualified_name() + current_name = qualified or (self._current_tool.name if self._current_tool else None) + if current_name == tool_name: + self._fetch_rating_if_needed(tool_name) + + def _on_review_action_error(self, error: str): + """Handle review action error.""" + self.main_window.show_status(f"Review error: {error}") + def _sync_tool_status(self): """Sync the moderation status of the selected tool from the registry.""" if not self._current_tool: return - tool_name = self._current_tool.name + tool_name = self._get_qualified_name() or self._current_tool.name config_path = get_tools_dir() / tool_name / "config.yaml" if not config_path.exists(): diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index b1d967e..64cce2c 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -4318,6 +4318,40 @@ def create_app() -> Flask: # ─── Reviews & Ratings API ──────────────────────────────────────────────────── + @app.route("/api/v1/tools///my-review", methods=["GET"]) + @require_token + def get_my_review(owner: str, name: str) -> Response: + """Get the current user's review for a tool.""" + if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): + return error_response("VALIDATION_ERROR", "Invalid owner or tool name") + + tool = query_one( + g.db, + "SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1", + [owner, name], + ) + if not tool: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404) + + review = query_one( + g.db, + "SELECT id, rating, title, content, created_at, updated_at FROM reviews WHERE tool_id = ? AND reviewer_id = ?", + [tool["id"], g.current_publisher["id"]], + ) + if not review: + return error_response("REVIEW_NOT_FOUND", "No review found", 404) + + return jsonify({ + "data": { + "id": review["id"], + "rating": review["rating"], + "title": review["title"], + "content": review["content"], + "created_at": review["created_at"], + "updated_at": review["updated_at"], + } + }) + @app.route("/api/v1/tools///reviews", methods=["POST"]) @require_token def submit_review(owner: str, name: str) -> Response: diff --git a/src/cmdforge/registry_client.py b/src/cmdforge/registry_client.py index 44d8106..365cc36 100644 --- a/src/cmdforge/registry_client.py +++ b/src/cmdforge/registry_client.py @@ -821,6 +821,138 @@ class RegistryClient: return response.json().get("data", {}) + # ------------------------------------------------------------------------- + # Reviews & Ratings + # ------------------------------------------------------------------------- + + def get_tool_rating(self, owner: str, name: str) -> Dict[str, Any]: + """ + Get rating summary for a tool. + + Args: + owner: Tool owner + name: Tool name + + Returns: + Dict with average_rating, rating_count, distribution, etc. + """ + response = self._request("GET", f"/tools/{owner}/{name}/rating") + + if response.status_code == 404: + raise RegistryError( + code="TOOL_NOT_FOUND", + message=f"Tool '{owner}/{name}' not found", + http_status=404 + ) + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", {}) + + def get_my_review(self, owner: str, name: str) -> Optional[Dict[str, Any]]: + """ + Get the current user's review for a tool. + + Args: + owner: Tool owner + name: Tool name + + Returns: + Review dict or None if no review exists + """ + response = self._request("GET", f"/tools/{owner}/{name}/my-review", require_auth=True) + + if response.status_code == 404: + # No review found - not an error + return None + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", {}) + + def submit_review( + self, owner: str, name: str, rating: int, title: str = "", content: str = "" + ) -> Dict[str, Any]: + """ + Submit a review for a tool. + + Args: + owner: Tool owner + name: Tool name + rating: Rating 1-5 + title: Optional review title + content: Optional review content + + Returns: + Dict with review id and data + """ + payload: Dict[str, Any] = {"rating": rating} + if title: + payload["title"] = title + if content: + payload["content"] = content + + response = self._request( + "POST", f"/tools/{owner}/{name}/reviews", + json_data=payload, + require_auth=True + ) + + if response.status_code not in (200, 201): + self._handle_error_response(response) + + return response.json().get("data", {}) + + def update_review( + self, review_id: int, rating: int, title: str = "", content: str = "" + ) -> Dict[str, Any]: + """ + Update an existing review. + + Args: + review_id: Review ID + rating: Rating 1-5 + title: Review title + content: Review content + + Returns: + Dict with status + """ + payload: Dict[str, Any] = {"rating": rating, "title": title, "content": content} + + response = self._request( + "PUT", f"/reviews/{review_id}", + json_data=payload, + require_auth=True + ) + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", {}) + + def delete_review(self, review_id: int) -> Dict[str, Any]: + """ + Delete a review. + + Args: + review_id: Review ID + + Returns: + Dict with status + """ + response = self._request( + "DELETE", f"/reviews/{review_id}", + require_auth=True + ) + + if response.status_code != 200: + self._handle_error_response(response) + + return response.json().get("data", {}) + def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]: """ Get most popular tools.