From 8d531b33048571921f11ad89d8a94472ed11f3f6 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Sun, 22 Mar 2026 22:26:14 -0500 Subject: [PATCH 1/7] Add initial GUI program --- pyproject.toml | 1 + trovedb/qgui/main_window.py | 53 +++++++++++++++++++++++++++++++++++++ trovedb/qgui/qtrove.py | 23 ++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 trovedb/qgui/main_window.py create mode 100644 trovedb/qgui/qtrove.py diff --git a/pyproject.toml b/pyproject.toml index 39c7876..90b7c61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [] [project.scripts] trove = "trovedb.cli:cli_main" +qtrove = "trovedb.qgui.qtrove:main" [project.optional-dependencies] dev = [ diff --git a/trovedb/qgui/main_window.py b/trovedb/qgui/main_window.py new file mode 100644 index 0000000..be56c30 --- /dev/null +++ b/trovedb/qgui/main_window.py @@ -0,0 +1,53 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( + QLabel, + QMainWindow, + QSplitter, + QStatusBar, + QToolBar, + QVBoxLayout, + QWidget +) + +class TroveMainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Trove") + + # ── Toolbar ── + toolbar = QToolBar("Main") + toolbar.setMovable(False) + self.addToolBar(toolbar) + + new_action = QAction("New", self) + new_action.setShortcut(QKeySequence.StandardKey.New) + toolbar.addAction(new_action) + + save_action = QAction("Save", self) + save_action.setShortcut(QKeySequence.StandardKey.Save) + toolbar.addAction(save_action) + + toolbar.addSeparator() + + # ── Central layout ── + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Horizontal splitter: tree | editor + splitter = QSplitter(Qt.Orientation.Horizontal) + + self._note_browser = QLabel("Note Browser") + splitter.addWidget(self._note_browser) + + self._tool = QLabel("View/Edit Tool") + splitter.addWidget(self._tool) + + layout.addWidget(splitter, stretch=1) + + # ── Status bar ── + self.setStatusBar(QStatusBar()) + self.statusBar().showMessage("Ready") diff --git a/trovedb/qgui/qtrove.py b/trovedb/qgui/qtrove.py new file mode 100644 index 0000000..766f660 --- /dev/null +++ b/trovedb/qgui/qtrove.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""PySide6 GUI for Trove""" + +import sys + +from PySide6.QtWidgets import ( + QApplication, +) + +from .main_window import TroveMainWindow + +def main(): + app = QApplication(sys.argv) + + # Respect system theme on KDE + app.setStyle("Fusion") + + window = TroveMainWindow() + window.show() + sys.exit(app.exec()) + +if __name__ == '__main__': + main() \ No newline at end of file From 72d591194f56680df67d2b2af6a7a454176a7131 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Sun, 22 Mar 2026 22:42:12 -0500 Subject: [PATCH 2/7] Save / Restore basic settings --- trovedb/qgui/main_window.py | 18 ++++++++++++++++++ trovedb/qgui/qtrove.py | 1 + trovedb/qgui/settings.py | 5 +++++ 3 files changed, 24 insertions(+) create mode 100644 trovedb/qgui/settings.py diff --git a/trovedb/qgui/main_window.py b/trovedb/qgui/main_window.py index be56c30..a33d4b1 100644 --- a/trovedb/qgui/main_window.py +++ b/trovedb/qgui/main_window.py @@ -10,6 +10,8 @@ from PySide6.QtWidgets import ( QWidget ) +from .settings import get_settings + class TroveMainWindow(QMainWindow): def __init__(self): super().__init__() @@ -17,6 +19,7 @@ class TroveMainWindow(QMainWindow): # ── Toolbar ── toolbar = QToolBar("Main") + toolbar.setObjectName("maintoolbar") toolbar.setMovable(False) self.addToolBar(toolbar) @@ -51,3 +54,18 @@ class TroveMainWindow(QMainWindow): # ── Status bar ── self.setStatusBar(QStatusBar()) self.statusBar().showMessage("Ready") + + def closeEvent(self, event): + settings = get_settings() + settings.setValue("window/geometry", self.saveGeometry()) + settings.setValue("window/state", self.saveState()) + super().closeEvent(event) + + def restore_settings(self): + settings = get_settings() + geometry = settings.value("window/geometry") + state = settings.value("window/state") + if geometry: + self.restoreGeometry(geometry) + if state: + self.restoreState(state) \ No newline at end of file diff --git a/trovedb/qgui/qtrove.py b/trovedb/qgui/qtrove.py index 766f660..5a18725 100644 --- a/trovedb/qgui/qtrove.py +++ b/trovedb/qgui/qtrove.py @@ -17,6 +17,7 @@ def main(): window = TroveMainWindow() window.show() + window.restore_settings() sys.exit(app.exec()) if __name__ == '__main__': diff --git a/trovedb/qgui/settings.py b/trovedb/qgui/settings.py new file mode 100644 index 0000000..40f9681 --- /dev/null +++ b/trovedb/qgui/settings.py @@ -0,0 +1,5 @@ +from PySide6.QtCore import QSettings + + +def get_settings() -> QSettings: + return QSettings("trovedb", "qtrove") From 4ada169bbe42fd46c95b3015373e0d08814036f3 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Sun, 22 Mar 2026 23:17:03 -0500 Subject: [PATCH 3/7] Add note browser pane --- trovedb/qgui/main_window.py | 7 +- trovedb/qgui/note_browser.py | 127 ++++++++++++++++++++++++++++++ trovedb/qgui/qtrove.py | 10 ++- trovedb/qgui/trove_tree_model.py | 129 +++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 trovedb/qgui/note_browser.py create mode 100644 trovedb/qgui/trove_tree_model.py diff --git a/trovedb/qgui/main_window.py b/trovedb/qgui/main_window.py index a33d4b1..daa611a 100644 --- a/trovedb/qgui/main_window.py +++ b/trovedb/qgui/main_window.py @@ -12,8 +12,11 @@ from PySide6.QtWidgets import ( from .settings import get_settings +from trovedb import trove as tr +from .note_browser import NoteBrowser + class TroveMainWindow(QMainWindow): - def __init__(self): + def __init__(self, trove: tr.Trove): super().__init__() self.setWindowTitle("Trove") @@ -43,7 +46,7 @@ class TroveMainWindow(QMainWindow): # Horizontal splitter: tree | editor splitter = QSplitter(Qt.Orientation.Horizontal) - self._note_browser = QLabel("Note Browser") + self._note_browser = NoteBrowser(trove) splitter.addWidget(self._note_browser) self._tool = QLabel("View/Edit Tool") diff --git a/trovedb/qgui/note_browser.py b/trovedb/qgui/note_browser.py new file mode 100644 index 0000000..6be5177 --- /dev/null +++ b/trovedb/qgui/note_browser.py @@ -0,0 +1,127 @@ +from __future__ import annotations +from typing import Optional, Callable +from PySide6.QtCore import Qt, QModelIndex, Signal +from PySide6.QtWidgets import ( + QWidget, QTreeView, QVBoxLayout, QHBoxLayout, + QPushButton, QMenu, QAbstractItemView, +) +from PySide6.QtGui import QAction + +from trovedb.trove import Note, TreeNote, Trove + +from .trove_tree_model import TroveTreeModel, TroveNode + + +class NoteBrowser(QWidget): + """ + A simple tree browser widget for a Trove. + + Signals: + note_selected(Note) -- emitted when a non-tree note is activated + tree_selected(TreeNote) -- emitted when a tree note is activated + """ + + note_selected = Signal(object) # Note + tree_selected = Signal(object) # TreeNote + + def __init__(self, trove: Trove, parent: Optional[QWidget] = None): + super().__init__(parent) + self._model = TroveTreeModel(trove) + self._setup_ui() + + # ------------------------------------------------------------------ + # UI setup + # ------------------------------------------------------------------ + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + self._tree = QTreeView() + self._tree.setModel(self._model) + self._tree.setHeaderHidden(True) + self._tree.setUniformRowHeights(True) + self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self._tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + self._tree.activated.connect(self._on_activated) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + + layout.addWidget(self._tree) + + # Minimal toolbar — refresh only for now + toolbar = QHBoxLayout() + toolbar.setContentsMargins(0, 0, 0, 0) + refresh_btn = QPushButton("Refresh") + refresh_btn.setFixedWidth(70) + refresh_btn.clicked.connect(self._on_refresh) + toolbar.addWidget(refresh_btn) + toolbar.addStretch() + layout.addLayout(toolbar) + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + def _on_activated(self, index: QModelIndex) -> None: + note = index.data(Qt.ItemDataRole.UserRole) + if note is None: + return + if isinstance(note, TreeNote): + self.tree_selected.emit(note) + else: + self.note_selected.emit(note) + + def _on_context_menu(self, pos) -> None: + index = self._tree.indexAt(pos) + menu = QMenu(self) + + refresh_action = QAction("Refresh", self) + refresh_action.triggered.connect( + lambda: self._model.invalidate_node(index) if index.isValid() else self._on_refresh() + ) + menu.addAction(refresh_action) + + # Placeholder hooks — wire up to a controller when ready + if index.isValid(): + node: TroveNode = index.internalPointer() + if node.is_tree(): + menu.addSeparator() + menu.addAction(QAction("New Note…", self)) # TODO: controller + menu.addAction(QAction("New Folder…", self)) # TODO: controller + menu.addSeparator() + menu.addAction(QAction("Rename…", self)) # TODO: controller + menu.addAction(QAction("Delete", self)) # TODO: controller + + menu.exec(self._tree.viewport().mapToGlobal(pos)) + + def _on_refresh(self) -> None: + """Full model reset — use until event-based invalidation is wired up.""" + self._model.invalidate_node(QModelIndex()) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def select_note(self, note: Note) -> None: + """Programmatically select the node matching the given note object.""" + match = self._find_index(self._model.index(0, 0, QModelIndex()), note) + if match is not None and match.isValid(): + self._tree.setCurrentIndex(match) + self._tree.scrollTo(match) + + def _find_index(self, start: QModelIndex, target: Note) -> Optional[QModelIndex]: + """DFS over visible model nodes looking for a matching Note.""" + if not start.isValid(): + return None + node: TroveNode = start.internalPointer() + if node.note is target: + return start + for row in range(self._model.rowCount(start)): + child = self._model.index(row, 0, start) + result = self._find_index(child, target) + if result is not None: + return result + return None \ No newline at end of file diff --git a/trovedb/qgui/qtrove.py b/trovedb/qgui/qtrove.py index 5a18725..e41b1e8 100644 --- a/trovedb/qgui/qtrove.py +++ b/trovedb/qgui/qtrove.py @@ -2,9 +2,11 @@ """PySide6 GUI for Trove""" import sys +from trovedb import trove_factory, user_env from PySide6.QtWidgets import ( QApplication, + QMessageBox, ) from .main_window import TroveMainWindow @@ -12,10 +14,16 @@ from .main_window import TroveMainWindow def main(): app = QApplication(sys.argv) + trove = trove_factory.get_trove(user_env.TROVEBASE) if user_env.TROVEBASE else None + + if trove is None: + QMessageBox.critical(None, "Error", "Trove Database Not Found") + sys.exit(1) + # Respect system theme on KDE app.setStyle("Fusion") - window = TroveMainWindow() + window = TroveMainWindow(trove) window.show() window.restore_settings() sys.exit(app.exec()) diff --git a/trovedb/qgui/trove_tree_model.py b/trovedb/qgui/trove_tree_model.py new file mode 100644 index 0000000..c74612f --- /dev/null +++ b/trovedb/qgui/trove_tree_model.py @@ -0,0 +1,129 @@ +from typing import Optional +from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, QObject + +from trovedb.trove import Note, TreeNote, Trove, ObjectId, TreeEntry + + +class TroveNode: + """Wrapper around a Note, caching children for tree model use.""" + + def __init__(self, note: Note, name: str, parent: 'Optional[TroveNode]' = None): + self.note = note + self.name = name + self.parent = parent + self._children: Optional[list[TroveNode]] = None # None = not yet loaded + + def is_tree(self) -> bool: + return isinstance(self.note, TreeNote) + + def children(self) -> 'list[TroveNode]': + """Lazy-load and cache children. Sorted by name.""" + if self._children is None: + if self.is_tree(): + tree: TreeNote = self.note # type: ignore[assignment] + entries: list[TreeEntry] = sorted(tree.entries(), key=lambda e: e.name) + self._children = [ + TroveNode(tree.child(e.name), e.name, parent=self) + for e in entries + ] + else: + self._children = [] + return self._children + + def invalidate(self) -> None: + """Clear cached children, forcing a reload on next access.""" + self._children = None + + def child_at(self, row: int) -> 'Optional[TroveNode]': + kids = self.children() + return kids[row] if 0 <= row < len(kids) else None + + def row(self) -> int: + if self.parent is None: + return 0 + return self.parent.children().index(self) + + def child_count(self) -> int: + return len(self.children()) + + +class TroveTreeModel(QAbstractItemModel): + """ + Read-only tree model backed by a Trove instance. + + The root TreeNote is treated as an invisible root; its children + are the top-level items in the view. + """ + + def __init__(self, trove: Trove, parent: Optional[QObject] = None): + super().__init__(parent) + root_note = trove.get_root() + self._root = TroveNode(root_note, "/", parent=None) + + # ------------------------------------------------------------------ + # QAbstractItemModel interface + # ------------------------------------------------------------------ + + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + if not self.hasIndex(row, column, parent): + return QModelIndex() + + parent_node = self._node_from_index(parent) + child = parent_node.child_at(row) + if child is None: + return QModelIndex() + return self.createIndex(row, column, child) + + def parent(self, index: QModelIndex) -> QModelIndex: # type: ignore[override] + if not index.isValid(): + return QModelIndex() + + node: TroveNode = index.internalPointer() # type: ignore[assignment] + parent_node = node.parent + + if parent_node is None or parent_node is self._root: + return QModelIndex() + + return self.createIndex(parent_node.row(), 0, parent_node) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + parent_node = self._node_from_index(parent) + return parent_node.child_count() + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return 1 + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> object: # type: ignore[assignment] + if not index.isValid(): + return None + node: TroveNode = index.internalPointer() # type: ignore[assignment] + if role == Qt.DisplayRole: + return node.name + if role == Qt.UserRole: + return node.note + return None + + def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool: + node = self._node_from_index(parent) + return node.is_tree() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _node_from_index(self, index: QModelIndex) -> TroveNode: + if index.isValid(): + node = index.internalPointer() + if node is not None: + return node # type: ignore[return-value] + return self._root + + def invalidate_node(self, index: QModelIndex) -> None: + """ + Force a reload of the node at index. + Call this after mutations to the underlying Trove tree. + """ + node = self._node_from_index(index) + self.beginResetModel() + node.invalidate() + self.endResetModel() \ No newline at end of file From bd4d571c95ce46b509993db445f768fda3fce9dc Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Sun, 22 Mar 2026 23:57:05 -0500 Subject: [PATCH 4/7] Add more meta information to trove entries --- trovedb/db.py | 11 ++++++++++- trovedb/fs.py | 24 ++++++++++++++++++++++++ trovedb/fuse/server.py | 14 ++++++++++---- trovedb/trove.py | 23 +++++++++++++++++++++-- trovedb/trovedb.py | 15 +++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/trovedb/db.py b/trovedb/db.py index e811028..72ee4c0 100644 --- a/trovedb/db.py +++ b/trovedb/db.py @@ -71,7 +71,7 @@ class Sqlite3Trove: con.commit() obj = cls(con) if initialize: - obj.write_blob(b"", NODE_ROOT_ID) + obj.write_blob(b"", NOTE_ROOT_ID) return obj def close(self): @@ -103,6 +103,15 @@ class Sqlite3Trove: return None return bytes(row["data"]) if row["data"] is not None else b"" + def get_mtime(self, object_id: int) -> datetime | None: + """Return the modified timestamp for an object, or None if not found.""" + row = self._con.execute( + "SELECT modified FROM objects WHERE id = ?", (object_id,) + ).fetchone() + if row is None: + return None + return datetime.fromisoformat(row["modified"]) + def read_metadata(self, object_id: int, key: str) -> bytes | None: """Return raw metadata value for (uuid, key), or None if not found.""" row = self._con.execute( diff --git a/trovedb/fs.py b/trovedb/fs.py index ff6eece..3f36792 100644 --- a/trovedb/fs.py +++ b/trovedb/fs.py @@ -1,6 +1,7 @@ import os import sqlite3 import tempfile +import datetime as dt from pathlib import Path from typing import Optional, Dict, List, Self, Iterable from .trove import Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType, TreeEntry, NoteNotFound @@ -24,6 +25,24 @@ class FSNote(Note): raise ValueError("Note not yet saved to disk") return self._inode + @property + def mtime(self): + """Return modification time as datetime.""" + stat = self._path.stat() + return dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc) + + @property + def readonly(self) -> bool: + """Check if the note is readonly based on file permissions.""" + if self._inode is None: + return False + return not os.access(self._path, os.W_OK) + + @property + def mime(self) -> str: + """Return MIME type, defaulting to generic binary stream.""" + return "application/octet-stream" + @property def _path(self) -> Path: if self._fs_path is not None: @@ -60,6 +79,11 @@ class FSBlobNote(FSNote, BlobNote): pass class FSTreeNote(FSNote, TreeNote): + @property + def mime(self) -> str: + """Return MIME type for directory/tree nodes.""" + return "inode/directory" + def link(self, name: str, note: Note): if not isinstance(note, FSBlobNote): raise BadNoteType("Only blob notes can be linked") diff --git a/trovedb/fuse/server.py b/trovedb/fuse/server.py index 2441314..f933972 100644 --- a/trovedb/fuse/server.py +++ b/trovedb/fuse/server.py @@ -194,20 +194,26 @@ class TroveFuseOps(pyfuse3.Operations): attr.st_nlink = 1 attr.st_uid = os.getuid() attr.st_gid = os.getgid() + + mtime_ns = int(note.mtime.timestamp() * 1e9) now_ns = int(time.time() * 1e9) attr.st_atime_ns = now_ns - attr.st_mtime_ns = now_ns - attr.st_ctime_ns = now_ns + attr.st_mtime_ns = mtime_ns + attr.st_ctime_ns = mtime_ns attr.generation = 0 attr.entry_timeout = 5.0 attr.attr_timeout = 5.0 + + # Determine permissions based on readonly property if is_tree: - attr.st_mode = stat.S_IFDIR | 0o755 + mode = 0o755 if not note.readonly else 0o555 + attr.st_mode = stat.S_IFDIR | mode attr.st_size = 0 attr.st_blksize = 512 attr.st_blocks = 0 else: - attr.st_mode = stat.S_IFREG | 0o644 + mode = 0o644 if not note.readonly else 0o444 + attr.st_mode = stat.S_IFREG | mode attr.st_size = size attr.st_blksize = 512 attr.st_blocks = (size + 511) // 512 diff --git a/trovedb/trove.py b/trovedb/trove.py index c9d0be9..924331f 100644 --- a/trovedb/trove.py +++ b/trovedb/trove.py @@ -1,6 +1,7 @@ from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, TypedDict from uuid import UUID from pathlib import PurePosixPath +import datetime as dt type ObjectId = int | str | UUID @@ -26,11 +27,27 @@ class Note(Protocol): Protocol for a Note item. Represents access to an individual note's content and metadata. """ + @property def object_id(self) -> ObjectId: """The unique identifier for this note.""" ... + @property + def mime(self) -> str: + """The MIME type of the note's content.""" + ... + + @property + def readonly(self) -> bool: + """Whether the note is read-only.""" + ... + + @property + def mtime(self) -> dt.datetime: + """The last modification time of the note.""" + ... + def get_raw_metadata(self, key: str) -> Optional[bytes]: """Retrieve metadata value for the given key.""" ... @@ -84,10 +101,12 @@ class Tree(Protocol): """Return all entries as {name: object_id}.""" ... -class BlobNote(Note, Blob): +@runtime_checkable +class BlobNote(Note, Blob, Protocol): """Blob Note""" -class TreeNote(Note, Tree): +@runtime_checkable +class TreeNote(Note, Tree, Protocol): """Tree Note""" diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 1cf2f2e..7141be4 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -8,6 +8,7 @@ tree serialization respectively. from typing import Optional, Self from pathlib import Path +import datetime as dt from .db import Sqlite3Trove, NOTE_ROOT_ID from .tree import Tree as TreeData @@ -30,6 +31,20 @@ class NoteImpl(Note): def object_id(self) -> int: return self._object_id + @property + def readonly(self) -> bool: + return False + + @property + def mtime(self) -> dt.datetime: + """Return modification time as Unix timestamp, or None if not set.""" + return self._db.get_mtime(self._object_id) + + @property + def mime(self) -> str: + """Return MIME type, defaulting to generic binary stream.""" + return "application/octet-stream" + def get_raw_metadata(self, key: str) -> Optional[bytes]: return self._db.read_metadata(self._object_id, key) From 160de4c5267f2a54d0eef5ed6a2a07497481e84a Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Mon, 23 Mar 2026 22:56:53 -0500 Subject: [PATCH 5/7] Simple text editor for basic notes --- trovedb/qgui/main_window.py | 17 +++++++++------- trovedb/qgui/note_browser.py | 20 +++++++++++-------- trovedb/qgui/note_tool_stack.py | 32 +++++++++++++++++++++++++++++++ trovedb/qgui/tool.py | 14 ++++++++++++++ trovedb/qgui/tool_basic_editor.py | 28 +++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 trovedb/qgui/note_tool_stack.py create mode 100644 trovedb/qgui/tool.py create mode 100644 trovedb/qgui/tool_basic_editor.py diff --git a/trovedb/qgui/main_window.py b/trovedb/qgui/main_window.py index daa611a..d4cdde4 100644 --- a/trovedb/qgui/main_window.py +++ b/trovedb/qgui/main_window.py @@ -1,7 +1,6 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import ( - QLabel, QMainWindow, QSplitter, QStatusBar, @@ -13,7 +12,9 @@ from PySide6.QtWidgets import ( from .settings import get_settings from trovedb import trove as tr + from .note_browser import NoteBrowser +from .note_tool_stack import NoteToolStack class TroveMainWindow(QMainWindow): def __init__(self, trove: tr.Trove): @@ -44,15 +45,17 @@ class TroveMainWindow(QMainWindow): layout.setSpacing(0) # Horizontal splitter: tree | editor - splitter = QSplitter(Qt.Orientation.Horizontal) + self._splitter = QSplitter(Qt.Orientation.Horizontal) self._note_browser = NoteBrowser(trove) - splitter.addWidget(self._note_browser) + self._splitter.addWidget(self._note_browser) - self._tool = QLabel("View/Edit Tool") - splitter.addWidget(self._tool) + self._tool_stack = NoteToolStack() + self._splitter.addWidget(self._tool_stack) - layout.addWidget(splitter, stretch=1) + layout.addWidget(self._splitter, stretch=1) + + self._note_browser.activeNoteChanged.connect(self._tool_stack.onNoteSelected) # ── Status bar ── self.setStatusBar(QStatusBar()) @@ -71,4 +74,4 @@ class TroveMainWindow(QMainWindow): if geometry: self.restoreGeometry(geometry) if state: - self.restoreState(state) \ No newline at end of file + self.restoreState(state) diff --git a/trovedb/qgui/note_browser.py b/trovedb/qgui/note_browser.py index 6be5177..ec5f178 100644 --- a/trovedb/qgui/note_browser.py +++ b/trovedb/qgui/note_browser.py @@ -21,8 +21,7 @@ class NoteBrowser(QWidget): tree_selected(TreeNote) -- emitted when a tree note is activated """ - note_selected = Signal(object) # Note - tree_selected = Signal(object) # TreeNote + activeNoteChanged = Signal(object) # Note def __init__(self, trove: Trove, parent: Optional[QWidget] = None): super().__init__(parent) @@ -38,6 +37,8 @@ class NoteBrowser(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) + self._activeNote: Optional[Note] = None + self._tree = QTreeView() self._tree.setModel(self._model) self._tree.setHeaderHidden(True) @@ -67,12 +68,7 @@ class NoteBrowser(QWidget): def _on_activated(self, index: QModelIndex) -> None: note = index.data(Qt.ItemDataRole.UserRole) - if note is None: - return - if isinstance(note, TreeNote): - self.tree_selected.emit(note) - else: - self.note_selected.emit(note) + self._handle_set_active_note(note) def _on_context_menu(self, pos) -> None: index = self._tree.indexAt(pos) @@ -105,6 +101,14 @@ class NoteBrowser(QWidget): # Public API # ------------------------------------------------------------------ + def _handle_set_active_note(self, note: Note | None) -> None: + """Called by the controller to update the active note.""" + if not isinstance(note, Note): + note = None + if self._activeNote != note: + self._activeNote = note + self.activeNoteChanged.emit(note) + def select_note(self, note: Note) -> None: """Programmatically select the node matching the given note object.""" match = self._find_index(self._model.index(0, 0, QModelIndex()), note) diff --git a/trovedb/qgui/note_tool_stack.py b/trovedb/qgui/note_tool_stack.py new file mode 100644 index 0000000..d5cb2c5 --- /dev/null +++ b/trovedb/qgui/note_tool_stack.py @@ -0,0 +1,32 @@ +from typing import Generator + +from PySide6.QtCore import Property, Slot +from PySide6.QtWidgets import QStackedWidget, QWidget, QLabel + +import trovedb.trove as tr + +from .tool import Tool +from .tool_basic_editor import ToolBasicEditor + + +class NoteToolStack(QStackedWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setContentsMargins(0, 0, 0, 0) + self.addWidget(QLabel("No note selected")) + + @Slot(object) + def onNoteSelected(self, note: tr.Note): + for tool in self._iter_tools(): + if tool.note == note: + self.setCurrentWidget(tool) + return + tool = ToolBasicEditor(note) + self.addWidget(tool) + self.setCurrentWidget(tool) + + def _iter_tools(self) -> Generator[Tool, None, None]: + for i in range(self.count()): + widget = self.widget(i) + if isinstance(widget, Tool): + yield widget diff --git a/trovedb/qgui/tool.py b/trovedb/qgui/tool.py new file mode 100644 index 0000000..53f2829 --- /dev/null +++ b/trovedb/qgui/tool.py @@ -0,0 +1,14 @@ +"""Tool Base Class""" + +from PySide6.QtWidgets import QWidget + +import trovedb.trove as tr + +class Tool(QWidget): + def __init__(self, note: tr.Note, parent=None): + super().__init__(parent) + self._note = note + + @property + def note(self) -> tr.Note: + return self._note diff --git a/trovedb/qgui/tool_basic_editor.py b/trovedb/qgui/tool_basic_editor.py new file mode 100644 index 0000000..530b88c --- /dev/null +++ b/trovedb/qgui/tool_basic_editor.py @@ -0,0 +1,28 @@ +"""Tool Supporting Basic Editor Functions""" +from typing import cast, Protocol +from PySide6.QtWidgets import QTextEdit, QVBoxLayout + +import trovedb.trove as tr +from .tool import Tool + + +class ToolBasicEditor(Tool): + def __init__(self, note, parent=None): + super().__init__(note, parent) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._text_edit = QTextEdit() + layout.addWidget(self._text_edit) + self.refresh() + + def _refresh_blob(self, note: tr.Blob): + self._text_edit.setPlainText(note.read().decode("utf-8")) + + def refresh(self): + if isinstance(self.note, tr.Blob): + self._refresh_blob(cast(tr.Blob, self.note)) + + From 90b72ed6785ed90b483d60569689e60a5364cc9e Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Mon, 23 Mar 2026 23:21:01 -0500 Subject: [PATCH 6/7] Single click selection preferred --- trovedb/qgui/note_browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trovedb/qgui/note_browser.py b/trovedb/qgui/note_browser.py index ec5f178..c672c00 100644 --- a/trovedb/qgui/note_browser.py +++ b/trovedb/qgui/note_browser.py @@ -47,7 +47,7 @@ class NoteBrowser(QWidget): self._tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self._tree.activated.connect(self._on_activated) + self._tree.selectionModel().currentChanged.connect(self._on_current_changed) self._tree.customContextMenuRequested.connect(self._on_context_menu) layout.addWidget(self._tree) @@ -66,7 +66,7 @@ class NoteBrowser(QWidget): # Slots # ------------------------------------------------------------------ - def _on_activated(self, index: QModelIndex) -> None: + def _on_current_changed(self, index: QModelIndex) -> None: note = index.data(Qt.ItemDataRole.UserRole) self._handle_set_active_note(note) From 82c272990ca1b9a9146f059e7a5bd5ac1787bb87 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Tue, 24 Mar 2026 21:22:24 -0500 Subject: [PATCH 7/7] Switch to UUID for sqlite db --- trovedb/db.py | 93 +++++++++++++++++++++++----------------------- trovedb/tree.py | 11 ++++-- trovedb/trovedb.py | 10 ++--- 3 files changed, 59 insertions(+), 55 deletions(-) diff --git a/trovedb/db.py b/trovedb/db.py index 72ee4c0..a4d846f 100644 --- a/trovedb/db.py +++ b/trovedb/db.py @@ -9,21 +9,22 @@ similar ideas to git storage. import argparse import sqlite3 import sys +import uuid from datetime import datetime, timezone from pathlib import Path -NOTE_ROOT_ID = 1 +NOTE_ROOT_ID = uuid.UUID(int=0) _SCHEMA = """ CREATE TABLE IF NOT EXISTS objects ( - id INTEGER PRIMARY KEY, + id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK(type IN ('blob', 'tree')), data BLOB, modified TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS metadata ( - id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE, + id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE, key TEXT NOT NULL, value BLOB, PRIMARY KEY (id, key) @@ -32,14 +33,21 @@ CREATE TABLE IF NOT EXISTS metadata ( CREATE TABLE IF NOT EXISTS labels ( label TEXT PRIMARY KEY, - id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE + id TEXT NOT NULL REFERENCES objects(id) ON DELETE CASCADE ); """ +type SqlObjectId = str | uuid.UUID def _now() -> str: return datetime.now(timezone.utc).isoformat() +def _sql_id(id: SqlObjectId | None) -> str | None: + if id is None: + return None + return ( + id if isinstance(id, str) else str(id) + ) def _initialize_db(con: sqlite3.Connection): con.executescript(_SCHEMA) @@ -71,7 +79,7 @@ class Sqlite3Trove: con.commit() obj = cls(con) if initialize: - obj.write_blob(b"", NOTE_ROOT_ID) + obj.write_tree(b"", NOTE_ROOT_ID) return obj def close(self): @@ -87,95 +95,88 @@ class Sqlite3Trove: # CRUD operations # ------------------------------------------------------------------ - def get_object_type(self, object_id: int) -> str | None: + def get_object_type(self, object_id: SqlObjectId) -> str | None: """Return the type column for an object, or None if not found.""" row = self._con.execute( - "SELECT type FROM objects WHERE id = ?", (object_id,) + "SELECT type FROM objects WHERE id = ?", (_sql_id(object_id),) ).fetchone() return row["type"] if row else None - def read_object(self, object_id: int) -> bytes | None: + def read_object(self, object_id: SqlObjectId) -> bytes | None: """Return raw data for a blob object, or None if not found.""" row = self._con.execute( - "SELECT data, type FROM objects WHERE id = ?", (object_id,) + "SELECT data, type FROM objects WHERE id = ?", (_sql_id(object_id),) ).fetchone() if row is None: return None return bytes(row["data"]) if row["data"] is not None else b"" - def get_mtime(self, object_id: int) -> datetime | None: + def get_mtime(self, object_id: SqlObjectId) -> datetime | None: """Return the modified timestamp for an object, or None if not found.""" row = self._con.execute( - "SELECT modified FROM objects WHERE id = ?", (object_id,) + "SELECT modified FROM objects WHERE id = ?", (_sql_id(object_id),) ).fetchone() if row is None: return None return datetime.fromisoformat(row["modified"]) - def read_metadata(self, object_id: int, key: str) -> bytes | None: + def read_metadata(self, object_id: SqlObjectId, key: str) -> bytes | None: """Return raw metadata value for (uuid, key), or None if not found.""" row = self._con.execute( - "SELECT value FROM metadata WHERE id = ? AND key = ?", (object_id, key) + "SELECT value FROM metadata WHERE id = ? AND key = ?", (_sql_id(object_id), key) ).fetchone() if row is None: return None return bytes(row["value"]) if row["value"] is not None else b"" - def write_metadata(self, object_id: int, key: str, value: bytes) -> None: + def write_metadata(self, object_id: SqlObjectId, key: str, value: bytes) -> None: """Upsert a metadata row. db.py has no write_metadata, so we go direct.""" self._con.execute( "INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)", - (object_id, key, value), + (_sql_id(object_id), key, value), ) self._con.commit() - def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int: + def _write_object(self, data: bytes, dtype: str, object_id: str | uuid.UUID | None = None) -> str: """ Insert or replace an object. Returns the id. - If object_id is None, creates a new object with auto-assigned ID. + If object_id is None, creates a new object with a new UUID. If object_id is provided, updates or creates the object with that ID. """ modified = _now() if object_id is None: - cur = self._con.execute( - "INSERT INTO objects (type, data, modified) VALUES (?, ?, ?)", - (dtype, data, modified) - ) - self._con.commit() - assert cur.lastrowid is not None - return cur.lastrowid - else: - self._con.execute( - "INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)", - (object_id, dtype, data, modified) - ) - self._con.commit() - return object_id + object_id = uuid.uuid4() + self._con.execute( + "INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)", + (_sql_id(object_id), dtype, data, modified) + ) + self._con.commit() + return _sql_id(object_id) - def write_blob(self, data: bytes, object_id: int | None = None) -> int: + def write_blob(self, data: bytes, object_id: SqlObjectId | None = None) -> str: """ Insert or replace a blob. Returns the id. Pass object_id to update an existing object. """ - return self._write_object(data, "blob", object_id) + return self._write_object(data, "blob", _sql_id(object_id)) - def write_tree(self, data: bytes, object_id: int | None = None) -> int: + def write_tree(self, data: bytes, object_id: SqlObjectId | None = None) -> str: """Write a tree-typed object. Returns the assigned id.""" - return self._write_object(data, "tree", object_id) + return self._write_object(data, "tree", _sql_id(object_id)) - def delete_object(self, object_id: int) -> bool: + def delete_object(self, object_id: SqlObjectId) -> bool: """ Delete a blob and all its metadata rows. Returns True if an object was deleted, False if id not found. Foreign key cascade handles the metadata rows. """ cur = self._con.execute( - "DELETE FROM objects WHERE id = ?", (object_id,) + "DELETE FROM objects WHERE id = ?", (_sql_id(object_id),) ) self._con.commit() return cur.rowcount > 0 - def get_label(self, label: str) -> int | None: + def get_label(self, label: str) -> SqlObjectId | None: """ Return the ID associated with a label, or None if not found. """ @@ -184,16 +185,16 @@ class Sqlite3Trove: ).fetchone() if row is None: return None - return row["id"] + return uuid.UUID(row["id"]) - def set_label(self, label: str, object_id: int) -> None: + def set_label(self, label: str, object_id: SqlObjectId) -> None: """ Set a label to point to an ID. Creates or updates the label. The ID must exist in the objects table. """ self._con.execute( "INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)", - (label, object_id), + (label, _sql_id(object_id)), ) self._con.commit() @@ -207,7 +208,7 @@ class Sqlite3Trove: self._con.commit() return cur.rowcount > 0 - def list_labels(self) -> list[tuple[str, int]]: + def list_labels(self) -> list[tuple[str, str]]: """ Return all labels as a list of (label, id) tuples. """ @@ -229,21 +230,21 @@ def main(): # Get blob get_parser = subparsers.add_parser("get", help="Get a blob object by ID") - get_parser.add_argument("id", type=int, help="ID of the blob to retrieve") + get_parser.add_argument("id", help="ID of the blob to retrieve") # Write blob write_parser = subparsers.add_parser("write", help="Write data to a blob") write_parser.add_argument("data", help="Data to write (as string, will be encoded as UTF-8)") - write_parser.add_argument("--id", type=int, help="ID of existing blob to update (optional)") + write_parser.add_argument("--id", help="ID of existing blob to update (optional)") # Delete blob delete_parser = subparsers.add_parser("delete", help="Delete a blob by ID") - delete_parser.add_argument("id", type=int, help="ID of the blob to delete") + delete_parser.add_argument("id", help="ID of the blob to delete") # Set label setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to an ID") setlabel_parser.add_argument("label", help="Label name") - setlabel_parser.add_argument("id", type=int, help="ID to associate with the label") + setlabel_parser.add_argument("id", help="ID to associate with the label") # Remove label rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a label") diff --git a/trovedb/tree.py b/trovedb/tree.py index 75c64ac..3336609 100644 --- a/trovedb/tree.py +++ b/trovedb/tree.py @@ -9,6 +9,9 @@ only the data field — storage is the caller's concern. import json +from .trove import ObjectId + + class Tree: def __init__(self, data: bytes | None = None): """ @@ -16,7 +19,7 @@ class Tree: An empty Tree is created if data is None. """ if not data: - self._entries: dict[str, int] = {} + self._entries: dict[str, ObjectId] = {} else: self._entries = json.loads(data.decode("utf-8")) @@ -24,11 +27,11 @@ class Tree: """Serialize the tree to UTF-8 JSON bytes.""" return json.dumps(self._entries).encode("utf-8") - def set_entry(self, name: str, object_id: int) -> None: + def set_entry(self, name: str, object_id: ObjectId) -> None: """Add or update an entry mapping name -> uuid.""" self._entries[name] = object_id - def get_entry(self, name: str) -> int: + def get_entry(self, name: str) -> ObjectId: """Get the uuid associated with a name, or raise KeyError if not found.""" return self._entries[name] @@ -36,6 +39,6 @@ class Tree: """Remove an entry by name. Raises KeyError if not found.""" del self._entries[name] - def list(self) -> dict[str, int]: + def list(self) -> dict[str, ObjectId]: """Return a shallow copy of all entries as {name: uuid}.""" return dict(self._entries) diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 7141be4..5e0e877 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -15,20 +15,20 @@ from .tree import Tree as TreeData from . import trove as tr -from .trove import Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound +from .trove import Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound, ObjectId class NoteImpl(Note): """Concrete not implementation""" - def __init__(self, parent: 'TroveImpl', object_id: int): + def __init__(self, parent: 'TroveImpl', object_id: ObjectId): self._parent = parent self._db = parent.db self._object_id = object_id # Note protocol @property - def object_id(self) -> int: + def object_id(self) -> ObjectId: return self._object_id @property @@ -123,7 +123,7 @@ class TreeNoteImpl(NoteImpl, TreeNote): for name, object_id in tree.list().items(): yield TreeEntry(name, object_id) - def list(self) -> dict[str, int]: + def list(self) -> dict[str, ObjectId]: """Return all entries as {name: object_id}.""" return self._read_tree().list() @@ -168,7 +168,7 @@ class TroveImpl: self.close() # Trove protocol - def get_raw_note(self, note_id: int) -> Note: + def get_raw_note(self, note_id: ObjectId) -> Note: """Return a BlobNote or TreeNote for the given id, or None if not found.""" ot = self._db.get_object_type(note_id) if ot is None: