devdash-mac 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devdash/__init__.py +4 -0
- devdash/__main__.py +6 -0
- devdash/app.py +145 -0
- devdash/clipboard.py +98 -0
- devdash/config.py +51 -0
- devdash/plugin_loader.py +43 -0
- devdash/storage.py +244 -0
- devdash/tools/__init__.py +1 -0
- devdash/tools/base.py +38 -0
- devdash/tools/base64_tool.py +79 -0
- devdash/tools/color_tool.py +108 -0
- devdash/tools/cron_tool.py +137 -0
- devdash/tools/hash_tool.py +65 -0
- devdash/tools/json_tool.py +61 -0
- devdash/tools/jwt_tool.py +97 -0
- devdash/tools/lorem_tool.py +102 -0
- devdash/tools/password_tool.py +366 -0
- devdash/tools/regex_tool.py +88 -0
- devdash/tools/timestamp_tool.py +142 -0
- devdash/tools/url_tool.py +75 -0
- devdash/tools/uuid_tool.py +119 -0
- devdash/ui/__init__.py +1 -0
- devdash/ui/notifications.py +22 -0
- devdash/ui/windows.py +84 -0
- devdash_mac-0.1.0.dist-info/METADATA +194 -0
- devdash_mac-0.1.0.dist-info/RECORD +30 -0
- devdash_mac-0.1.0.dist-info/WHEEL +5 -0
- devdash_mac-0.1.0.dist-info/entry_points.txt +2 -0
- devdash_mac-0.1.0.dist-info/licenses/LICENSE +21 -0
- devdash_mac-0.1.0.dist-info/top_level.txt +1 -0
devdash/__init__.py
ADDED
devdash/__main__.py
ADDED
devdash/app.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Main DevDash application - macOS menubar app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import rumps
|
|
6
|
+
|
|
7
|
+
from devdash import __app_name__, __version__, clipboard
|
|
8
|
+
from devdash.config import load_config
|
|
9
|
+
from devdash.plugin_loader import discover_tools
|
|
10
|
+
from devdash.tools.base import DevTool
|
|
11
|
+
from devdash.ui.notifications import notify
|
|
12
|
+
from devdash.ui.windows import show_tool_dialog
|
|
13
|
+
|
|
14
|
+
# Content type display names for notifications
|
|
15
|
+
_TYPE_NAMES: dict[clipboard.ContentType, str] = {
|
|
16
|
+
clipboard.ContentType.JSON: "JSON",
|
|
17
|
+
clipboard.ContentType.JWT: "JWT token",
|
|
18
|
+
clipboard.ContentType.UUID: "UUID",
|
|
19
|
+
clipboard.ContentType.BASE64: "Base64",
|
|
20
|
+
clipboard.ContentType.UNIX_TIMESTAMP: "Unix timestamp",
|
|
21
|
+
clipboard.ContentType.URL: "URL",
|
|
22
|
+
clipboard.ContentType.URL_ENCODED: "URL-encoded text",
|
|
23
|
+
clipboard.ContentType.HEX_COLOR: "HEX color",
|
|
24
|
+
clipboard.ContentType.CRON: "Cron expression",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DevDashApp(rumps.App):
|
|
29
|
+
"""macOS menubar developer utilities app."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__(name=__app_name__, title="\U0001f527", quit_button=None)
|
|
33
|
+
self._tools: list[DevTool] = discover_tools()
|
|
34
|
+
self._last_clipboard: str = ""
|
|
35
|
+
self._build_menu()
|
|
36
|
+
# Start clipboard watcher if enabled in config
|
|
37
|
+
config = load_config()
|
|
38
|
+
if config.get("clipboard_watcher", False):
|
|
39
|
+
self._start_clipboard_watcher()
|
|
40
|
+
|
|
41
|
+
def _build_menu(self) -> None:
|
|
42
|
+
"""Build the menubar dropdown from discovered tools."""
|
|
43
|
+
menu_items: list[rumps.MenuItem | None] = []
|
|
44
|
+
|
|
45
|
+
# Auto-detect clipboard item
|
|
46
|
+
auto_detect = rumps.MenuItem("Clipboard: Auto-detect", callback=self._on_auto_detect)
|
|
47
|
+
menu_items.append(auto_detect)
|
|
48
|
+
menu_items.append(None) # separator
|
|
49
|
+
|
|
50
|
+
# Group tools by category
|
|
51
|
+
current_category = ""
|
|
52
|
+
for tool in self._tools:
|
|
53
|
+
if tool.category != current_category:
|
|
54
|
+
if current_category:
|
|
55
|
+
menu_items.append(None) # separator between categories
|
|
56
|
+
current_category = tool.category
|
|
57
|
+
item = rumps.MenuItem(tool.name, callback=self._make_tool_callback(tool))
|
|
58
|
+
menu_items.append(item)
|
|
59
|
+
|
|
60
|
+
menu_items.append(None) # separator
|
|
61
|
+
|
|
62
|
+
# About and Quit
|
|
63
|
+
about = rumps.MenuItem(f"About {__app_name__}", callback=self._on_about)
|
|
64
|
+
quit_item = rumps.MenuItem("Quit", callback=self._on_quit)
|
|
65
|
+
menu_items.append(about)
|
|
66
|
+
menu_items.append(quit_item)
|
|
67
|
+
|
|
68
|
+
self.menu = menu_items
|
|
69
|
+
|
|
70
|
+
def _make_tool_callback(self, tool: DevTool): # type: ignore[no-untyped-def]
|
|
71
|
+
"""Create a callback closure for a specific tool."""
|
|
72
|
+
|
|
73
|
+
def callback(_: rumps.MenuItem) -> None:
|
|
74
|
+
show_tool_dialog(tool)
|
|
75
|
+
|
|
76
|
+
return callback
|
|
77
|
+
|
|
78
|
+
def _on_auto_detect(self, _: rumps.MenuItem) -> None:
|
|
79
|
+
"""Read clipboard, detect content type, open matching tool."""
|
|
80
|
+
content = clipboard.read()
|
|
81
|
+
if not content.strip():
|
|
82
|
+
notify("DevDash", "Clipboard is empty")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
detected = clipboard.detect_type(content)
|
|
86
|
+
# Find matching tool by keyword
|
|
87
|
+
keyword_map = {
|
|
88
|
+
clipboard.ContentType.JSON: "json",
|
|
89
|
+
clipboard.ContentType.JWT: "jwt",
|
|
90
|
+
clipboard.ContentType.UUID: "uuid",
|
|
91
|
+
clipboard.ContentType.BASE64: "base64",
|
|
92
|
+
clipboard.ContentType.UNIX_TIMESTAMP: "timestamp",
|
|
93
|
+
clipboard.ContentType.URL: "url",
|
|
94
|
+
clipboard.ContentType.URL_ENCODED: "url",
|
|
95
|
+
clipboard.ContentType.HEX_COLOR: "color",
|
|
96
|
+
clipboard.ContentType.CRON: "cron",
|
|
97
|
+
}
|
|
98
|
+
keyword = keyword_map.get(detected)
|
|
99
|
+
if keyword:
|
|
100
|
+
for tool in self._tools:
|
|
101
|
+
if tool.keyword == keyword:
|
|
102
|
+
show_tool_dialog(tool, input_text=content)
|
|
103
|
+
return
|
|
104
|
+
notify("DevDash", f"Detected: {detected.name}. No matching tool found.")
|
|
105
|
+
|
|
106
|
+
def _on_about(self, _: rumps.MenuItem) -> None:
|
|
107
|
+
"""Show about dialog."""
|
|
108
|
+
rumps.alert(
|
|
109
|
+
title=f"About {__app_name__}",
|
|
110
|
+
message=f"{__app_name__} v{__version__}\n\n"
|
|
111
|
+
"Open-source macOS menubar developer utilities.\n"
|
|
112
|
+
"https://github.com/devdash/devdash",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _on_quit(self, _: rumps.MenuItem) -> None:
|
|
116
|
+
"""Quit the app."""
|
|
117
|
+
rumps.quit_application()
|
|
118
|
+
|
|
119
|
+
def _start_clipboard_watcher(self) -> None:
|
|
120
|
+
"""Start background clipboard polling (opt-in, privacy conscious)."""
|
|
121
|
+
|
|
122
|
+
@rumps.timer(2)
|
|
123
|
+
def _watch_clipboard(timer: rumps.Timer) -> None:
|
|
124
|
+
try:
|
|
125
|
+
content = clipboard.read()
|
|
126
|
+
if not content or content == self._last_clipboard:
|
|
127
|
+
return
|
|
128
|
+
self._last_clipboard = content
|
|
129
|
+
detected = clipboard.detect_type(content)
|
|
130
|
+
if detected != clipboard.ContentType.PLAIN_TEXT:
|
|
131
|
+
type_name = _TYPE_NAMES.get(detected, detected.name)
|
|
132
|
+
notify(
|
|
133
|
+
"DevDash",
|
|
134
|
+
f"Detected {type_name} in your clipboard. Click to process.",
|
|
135
|
+
)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass # Never crash the watcher
|
|
138
|
+
|
|
139
|
+
_watch_clipboard.start() # type: ignore[attr-defined]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
"""Entry point for the application."""
|
|
144
|
+
app = DevDashApp()
|
|
145
|
+
app.run()
|
devdash/clipboard.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Clipboard utilities with content type auto-detection."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
import pyperclip
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContentType(Enum):
|
|
11
|
+
"""Detected content types for clipboard auto-detection."""
|
|
12
|
+
|
|
13
|
+
JWT = auto()
|
|
14
|
+
JSON = auto()
|
|
15
|
+
UUID = auto()
|
|
16
|
+
CRON = auto()
|
|
17
|
+
URL_ENCODED = auto()
|
|
18
|
+
BASE64 = auto()
|
|
19
|
+
UNIX_TIMESTAMP = auto()
|
|
20
|
+
HEX_COLOR = auto()
|
|
21
|
+
URL = auto()
|
|
22
|
+
PLAIN_TEXT = auto()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_UUID_RE = re.compile(
|
|
26
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
|
27
|
+
)
|
|
28
|
+
_CRON_FIELD = r"[\d*,/\-LW#?]+"
|
|
29
|
+
_CRON_RE = re.compile(rf"^{_CRON_FIELD}(\s+{_CRON_FIELD}){{4,5}}$")
|
|
30
|
+
_URL_ENCODED_RE = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
31
|
+
_BASE64_RE = re.compile(r"^[A-Za-z0-9+/\n\r]+=*$")
|
|
32
|
+
_HEX_COLOR_RE = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read() -> str:
|
|
36
|
+
"""Get current clipboard content as string."""
|
|
37
|
+
return pyperclip.paste()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write(text: str) -> None:
|
|
41
|
+
"""Write string to clipboard."""
|
|
42
|
+
pyperclip.copy(text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_type(text: str) -> ContentType:
|
|
46
|
+
"""Auto-detect content type with priority-based detection."""
|
|
47
|
+
if not text or not text.strip():
|
|
48
|
+
return ContentType.PLAIN_TEXT
|
|
49
|
+
|
|
50
|
+
stripped = text.strip()
|
|
51
|
+
|
|
52
|
+
# 1. JWT: starts with "eyJ" and has exactly 2 dots
|
|
53
|
+
if stripped.startswith("eyJ") and stripped.count(".") == 2:
|
|
54
|
+
return ContentType.JWT
|
|
55
|
+
|
|
56
|
+
# 2. JSON: starts with { or [ and is valid JSON
|
|
57
|
+
if stripped.startswith(("{", "[")):
|
|
58
|
+
try:
|
|
59
|
+
json.loads(stripped)
|
|
60
|
+
return ContentType.JSON
|
|
61
|
+
except (json.JSONDecodeError, ValueError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# 3. UUID: matches 8-4-4-4-12 hex pattern
|
|
65
|
+
if _UUID_RE.match(stripped):
|
|
66
|
+
return ContentType.UUID
|
|
67
|
+
|
|
68
|
+
# 4. Cron: 5-6 space-separated fields
|
|
69
|
+
if _CRON_RE.match(stripped):
|
|
70
|
+
return ContentType.CRON
|
|
71
|
+
|
|
72
|
+
# 5. URL-encoded: contains %XX patterns
|
|
73
|
+
if _URL_ENCODED_RE.search(stripped) and " " not in stripped:
|
|
74
|
+
return ContentType.URL_ENCODED
|
|
75
|
+
|
|
76
|
+
# 6. Base64: matches charset and length multiple of 4
|
|
77
|
+
if len(stripped) >= 4 and len(stripped) % 4 == 0 and _BASE64_RE.match(stripped):
|
|
78
|
+
return ContentType.BASE64
|
|
79
|
+
|
|
80
|
+
# 7. Unix timestamp: pure digits, 10 or 13 chars, reasonable range
|
|
81
|
+
if stripped.isdigit() and len(stripped) in (10, 13):
|
|
82
|
+
ts = int(stripped)
|
|
83
|
+
if len(stripped) == 13:
|
|
84
|
+
ts = ts // 1000
|
|
85
|
+
# Reasonable range: 1970 to 2100
|
|
86
|
+
if 0 <= ts <= 4102444800:
|
|
87
|
+
return ContentType.UNIX_TIMESTAMP
|
|
88
|
+
|
|
89
|
+
# 8. HEX color: starts with # + 3 or 6 hex chars
|
|
90
|
+
if _HEX_COLOR_RE.match(stripped):
|
|
91
|
+
return ContentType.HEX_COLOR
|
|
92
|
+
|
|
93
|
+
# 9. URL: starts with http:// or https://
|
|
94
|
+
if stripped.startswith(("http://", "https://")):
|
|
95
|
+
return ContentType.URL
|
|
96
|
+
|
|
97
|
+
# 10. Fallback
|
|
98
|
+
return ContentType.PLAIN_TEXT
|
devdash/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""User preferences management with YAML config."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".config" / "devdash"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
12
|
+
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
16
|
+
"default_hash_algorithm": "sha256",
|
|
17
|
+
"timestamp_format": "%Y-%m-%d %H:%M:%S",
|
|
18
|
+
"password_length": 16,
|
|
19
|
+
"uuid_version": "v4",
|
|
20
|
+
"auto_clipboard_detection": True,
|
|
21
|
+
"clipboard_watcher": False,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_config_dir() -> None:
|
|
26
|
+
"""Create config directory if it doesn't exist."""
|
|
27
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config() -> dict[str, Any]:
|
|
31
|
+
"""Load config from file, creating defaults on first run."""
|
|
32
|
+
with _lock:
|
|
33
|
+
if not CONFIG_FILE.exists():
|
|
34
|
+
save_config(DEFAULT_CONFIG)
|
|
35
|
+
return dict(DEFAULT_CONFIG)
|
|
36
|
+
with open(CONFIG_FILE) as f:
|
|
37
|
+
data = yaml.safe_load(f)
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
return dict(DEFAULT_CONFIG)
|
|
40
|
+
# Merge with defaults for any missing keys
|
|
41
|
+
merged = dict(DEFAULT_CONFIG)
|
|
42
|
+
merged.update(data)
|
|
43
|
+
return merged
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
47
|
+
"""Save config to file with restricted permissions."""
|
|
48
|
+
_ensure_config_dir()
|
|
49
|
+
with open(CONFIG_FILE, "w") as f:
|
|
50
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
51
|
+
os.chmod(CONFIG_FILE, 0o600)
|
devdash/plugin_loader.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Dynamic tool discovery and registration."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
import pkgutil
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from devdash.tools.base import DevTool
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def discover_tools() -> list["DevTool"]:
|
|
15
|
+
"""Scan the tools/ directory and return registered tool instances."""
|
|
16
|
+
tools: list[DevTool] = []
|
|
17
|
+
package_name = "devdash.tools"
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
package = importlib.import_module(package_name)
|
|
21
|
+
except ImportError:
|
|
22
|
+
logger.error("Could not import tools package")
|
|
23
|
+
return tools
|
|
24
|
+
|
|
25
|
+
package_path = getattr(package, "__path__", None)
|
|
26
|
+
if package_path is None:
|
|
27
|
+
return tools
|
|
28
|
+
|
|
29
|
+
for _importer, module_name, _ispkg in pkgutil.iter_modules(package_path):
|
|
30
|
+
if module_name.startswith("_") or module_name == "base":
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
module = importlib.import_module(f"{package_name}.{module_name}")
|
|
34
|
+
register_fn = getattr(module, "register", None)
|
|
35
|
+
if register_fn and callable(register_fn):
|
|
36
|
+
tool = register_fn()
|
|
37
|
+
tools.append(tool)
|
|
38
|
+
except Exception:
|
|
39
|
+
logger.exception("Failed to load tool module: %s", module_name)
|
|
40
|
+
|
|
41
|
+
# Sort by category then name
|
|
42
|
+
tools.sort(key=lambda t: (t.category, t.name))
|
|
43
|
+
return tools
|
devdash/storage.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Optional SQLite storage for tool history and favorites.
|
|
2
|
+
|
|
3
|
+
The database is created lazily on first access and stored at
|
|
4
|
+
~/.config/devdash/history.db with restricted (0o600) permissions.
|
|
5
|
+
All public methods are thread-safe — each call opens its own
|
|
6
|
+
connection so there is no shared state between threads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from devdash.config import CONFIG_DIR
|
|
15
|
+
|
|
16
|
+
DB_PATH: Path = CONFIG_DIR / "history.db"
|
|
17
|
+
_PREVIEW_MAX = 200
|
|
18
|
+
_HISTORY_KEEP = 100
|
|
19
|
+
|
|
20
|
+
_init_lock = threading.Lock()
|
|
21
|
+
_initialized = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _truncate(text: str | None, limit: int = _PREVIEW_MAX) -> str | None:
|
|
25
|
+
"""Truncate *text* to *limit* characters, appending an ellipsis if cut."""
|
|
26
|
+
if text is None:
|
|
27
|
+
return None
|
|
28
|
+
if len(text) <= limit:
|
|
29
|
+
return text
|
|
30
|
+
return text[: limit - 1] + "\u2026"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_db() -> None:
|
|
34
|
+
"""Create the database file and tables if they do not yet exist.
|
|
35
|
+
|
|
36
|
+
Uses a module-level lock so the schema is applied at most once per
|
|
37
|
+
process, regardless of how many threads call in concurrently.
|
|
38
|
+
"""
|
|
39
|
+
global _initialized
|
|
40
|
+
if _initialized:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
with _init_lock:
|
|
44
|
+
# Double-check after acquiring the lock.
|
|
45
|
+
if _initialized:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
con = sqlite3.connect(str(DB_PATH))
|
|
50
|
+
try:
|
|
51
|
+
con.executescript(
|
|
52
|
+
"""\
|
|
53
|
+
CREATE TABLE IF NOT EXISTS tool_history (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
tool_name TEXT NOT NULL,
|
|
56
|
+
input_preview TEXT,
|
|
57
|
+
output_preview TEXT,
|
|
58
|
+
timestamp TEXT DEFAULT CURRENT_TIMESTAMP
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_tool_history_tool
|
|
62
|
+
ON tool_history (tool_name, timestamp DESC);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
tool_name TEXT NOT NULL,
|
|
67
|
+
label TEXT NOT NULL,
|
|
68
|
+
content TEXT NOT NULL
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_favorites_tool
|
|
72
|
+
ON favorites (tool_name);
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
finally:
|
|
76
|
+
con.close()
|
|
77
|
+
|
|
78
|
+
os.chmod(DB_PATH, 0o600)
|
|
79
|
+
_initialized = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _connect() -> sqlite3.Connection:
|
|
83
|
+
"""Return a new connection to the history database.
|
|
84
|
+
|
|
85
|
+
Ensures the schema exists before handing back the connection.
|
|
86
|
+
"""
|
|
87
|
+
_ensure_db()
|
|
88
|
+
con = sqlite3.connect(str(DB_PATH))
|
|
89
|
+
con.row_factory = sqlite3.Row
|
|
90
|
+
return con
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class HistoryStore:
|
|
94
|
+
"""High-level interface to the DevDash history / favorites database.
|
|
95
|
+
|
|
96
|
+
Supports use as a context manager::
|
|
97
|
+
|
|
98
|
+
with HistoryStore() as store:
|
|
99
|
+
store.add_history("base64", "hello", "aGVsbG8=")
|
|
100
|
+
|
|
101
|
+
When used without a context manager, call :meth:`close` explicitly
|
|
102
|
+
or simply let the garbage collector handle it — each public method
|
|
103
|
+
opens (and closes) its own connection, so the instance itself holds
|
|
104
|
+
no long-lived resources.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._closed = False
|
|
109
|
+
|
|
110
|
+
# -- context manager ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> "HistoryStore":
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001
|
|
116
|
+
self.close()
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Mark the store as closed. Subsequent calls will raise."""
|
|
121
|
+
self._closed = True
|
|
122
|
+
|
|
123
|
+
def _check_open(self) -> None:
|
|
124
|
+
if self._closed:
|
|
125
|
+
raise RuntimeError("HistoryStore is closed")
|
|
126
|
+
|
|
127
|
+
# -- tool_history -------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def add_history(
|
|
130
|
+
self,
|
|
131
|
+
tool_name: str,
|
|
132
|
+
input_text: str | None = None,
|
|
133
|
+
output_text: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Record a tool invocation, then auto-cleanup old rows."""
|
|
136
|
+
self._check_open()
|
|
137
|
+
in_prev = _truncate(input_text)
|
|
138
|
+
out_prev = _truncate(output_text)
|
|
139
|
+
con = _connect()
|
|
140
|
+
try:
|
|
141
|
+
con.execute(
|
|
142
|
+
"INSERT INTO tool_history (tool_name, input_preview, output_preview) "
|
|
143
|
+
"VALUES (?, ?, ?)",
|
|
144
|
+
(tool_name, in_prev, out_prev),
|
|
145
|
+
)
|
|
146
|
+
con.commit()
|
|
147
|
+
finally:
|
|
148
|
+
con.close()
|
|
149
|
+
self.cleanup(tool_name)
|
|
150
|
+
|
|
151
|
+
def get_history(self, tool_name: str, limit: int = 20) -> list[dict]:
|
|
152
|
+
"""Return the most recent *limit* history rows for *tool_name*."""
|
|
153
|
+
self._check_open()
|
|
154
|
+
con = _connect()
|
|
155
|
+
try:
|
|
156
|
+
rows = con.execute(
|
|
157
|
+
"SELECT id, tool_name, input_preview, output_preview, timestamp "
|
|
158
|
+
"FROM tool_history "
|
|
159
|
+
"WHERE tool_name = ? "
|
|
160
|
+
"ORDER BY timestamp DESC "
|
|
161
|
+
"LIMIT ?",
|
|
162
|
+
(tool_name, limit),
|
|
163
|
+
).fetchall()
|
|
164
|
+
return [dict(r) for r in rows]
|
|
165
|
+
finally:
|
|
166
|
+
con.close()
|
|
167
|
+
|
|
168
|
+
# -- favorites ----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def add_favorite(self, tool_name: str, label: str, content: str) -> int:
|
|
171
|
+
"""Save a favorite and return its row id."""
|
|
172
|
+
self._check_open()
|
|
173
|
+
con = _connect()
|
|
174
|
+
try:
|
|
175
|
+
cur = con.execute(
|
|
176
|
+
"INSERT INTO favorites (tool_name, label, content) VALUES (?, ?, ?)",
|
|
177
|
+
(tool_name, label, content),
|
|
178
|
+
)
|
|
179
|
+
con.commit()
|
|
180
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
181
|
+
finally:
|
|
182
|
+
con.close()
|
|
183
|
+
|
|
184
|
+
def get_favorites(self, tool_name: str) -> list[dict]:
|
|
185
|
+
"""Return all favorites for *tool_name*, ordered by id."""
|
|
186
|
+
self._check_open()
|
|
187
|
+
con = _connect()
|
|
188
|
+
try:
|
|
189
|
+
rows = con.execute(
|
|
190
|
+
"SELECT id, tool_name, label, content "
|
|
191
|
+
"FROM favorites "
|
|
192
|
+
"WHERE tool_name = ? "
|
|
193
|
+
"ORDER BY id",
|
|
194
|
+
(tool_name,),
|
|
195
|
+
).fetchall()
|
|
196
|
+
return [dict(r) for r in rows]
|
|
197
|
+
finally:
|
|
198
|
+
con.close()
|
|
199
|
+
|
|
200
|
+
def remove_favorite(self, favorite_id: int) -> bool:
|
|
201
|
+
"""Delete a favorite by id. Returns True if a row was deleted."""
|
|
202
|
+
self._check_open()
|
|
203
|
+
con = _connect()
|
|
204
|
+
try:
|
|
205
|
+
cur = con.execute("DELETE FROM favorites WHERE id = ?", (favorite_id,))
|
|
206
|
+
con.commit()
|
|
207
|
+
return cur.rowcount > 0
|
|
208
|
+
finally:
|
|
209
|
+
con.close()
|
|
210
|
+
|
|
211
|
+
# -- maintenance --------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def cleanup(self, tool_name: str | None = None) -> None:
|
|
214
|
+
"""Trim tool_history to the newest *_HISTORY_KEEP* rows per tool.
|
|
215
|
+
|
|
216
|
+
If *tool_name* is given, only that tool is cleaned. Otherwise
|
|
217
|
+
every tool present in the table is processed.
|
|
218
|
+
"""
|
|
219
|
+
self._check_open()
|
|
220
|
+
con = _connect()
|
|
221
|
+
try:
|
|
222
|
+
if tool_name is not None:
|
|
223
|
+
tools = [tool_name]
|
|
224
|
+
else:
|
|
225
|
+
tools = [
|
|
226
|
+
row["tool_name"]
|
|
227
|
+
for row in con.execute("SELECT DISTINCT tool_name FROM tool_history").fetchall()
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
for name in tools:
|
|
231
|
+
con.execute(
|
|
232
|
+
"DELETE FROM tool_history "
|
|
233
|
+
"WHERE tool_name = ? "
|
|
234
|
+
"AND id NOT IN ("
|
|
235
|
+
" SELECT id FROM tool_history "
|
|
236
|
+
" WHERE tool_name = ? "
|
|
237
|
+
" ORDER BY timestamp DESC "
|
|
238
|
+
" LIMIT ?"
|
|
239
|
+
")",
|
|
240
|
+
(name, name, _HISTORY_KEEP),
|
|
241
|
+
)
|
|
242
|
+
con.commit()
|
|
243
|
+
finally:
|
|
244
|
+
con.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DevDash tools registry."""
|
devdash/tools/base.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Abstract base class for all DevDash tools."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DevTool(ABC):
|
|
7
|
+
"""Base class for all DevDash tools."""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def name(self) -> str:
|
|
12
|
+
"""Display name in the menu."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def keyword(self) -> str:
|
|
18
|
+
"""Short identifier for CLI and auto-detection."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def category(self) -> str:
|
|
23
|
+
"""Menu category grouping."""
|
|
24
|
+
return "General"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def description(self) -> str:
|
|
28
|
+
"""One-line description shown in tooltip."""
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def process(self, input_text: str, **kwargs: object) -> str:
|
|
33
|
+
"""Process input and return output string."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def validate(self, input_text: str) -> str | None:
|
|
37
|
+
"""Validate input. Return error message or None if valid."""
|
|
38
|
+
return None
|