mdbub 0.3.7__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.
@@ -0,0 +1,141 @@
1
+ """
2
+ quickmode_config.py - Loads and manages Quick Mode UI configuration (colors, symbols, caps, etc.)
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import toml
12
+
13
+ # Default UI constants
14
+ QUICKMODE_DEFAULTS = {
15
+ # Semantic ANSI color and highlight codes for terminal prints
16
+ # Semantic color roles (legacy single-value keys for backward compatibility)
17
+ "COLOR_PRINTS_CLEAR": "\033[H\033[J", # Clear screen
18
+ "COLOR_PRINTS_RESET": "\033[0m", # Reset
19
+ "COLOR_PRINTS_BLINK": "blink", # Rich blink style (not ANSI)
20
+ # Foreground/background split for all roles (preferred)
21
+ "COLOR_PRINTS_ACCENT_FG": "\033[36m",
22
+ "COLOR_PRINTS_ACCENT_BG": "",
23
+ "COLOR_PRINTS_HIGHLIGHT_FG": "",
24
+ "COLOR_PRINTS_HIGHLIGHT_BG": "\033[44m",
25
+ "COLOR_PRINTS_TEXT_FG": "\033[97m",
26
+ "COLOR_PRINTS_TEXT_BG": "",
27
+ "COLOR_PRINTS_DIM_FG": "\033[90m",
28
+ "COLOR_PRINTS_DIM_BG": "",
29
+ "COLOR_PRINTS_WARNING_FG": "\033[33m",
30
+ "COLOR_PRINTS_WARNING_BG": "",
31
+ "COLOR_PRINTS_SUCCESS_FG": "\033[32m",
32
+ "COLOR_PRINTS_SUCCESS_BG": "",
33
+ "COLOR_PRINTS_ERROR_FG": "\033[31m",
34
+ "COLOR_PRINTS_ERROR_BG": "",
35
+ "COLOR_PRINTS_STATUS_BAR_FG": "\u001b[93m",
36
+ "COLOR_PRINTS_STATUS_BAR_BG": "",
37
+ "COLOR_PRINTS_BREADCRUMB_BAR_FG": "\u001b[36m",
38
+ "COLOR_PRINTS_BREADCRUMB_BAR_BG": "",
39
+ "COLOR_PRINTS_CHILD_HIGHLIGHT_FG": "\u001b[90m",
40
+ "COLOR_PRINTS_CHILD_HIGHLIGHT_BG": "", # Child highlight (default: dim gray, no bg)
41
+ # UI timings
42
+ "STATUS_MESSAGE_TIMEOUT_SHORT": 1.0,
43
+ "STATUS_MESSAGE_TIMEOUT": 2.0,
44
+ "STATUS_MESSAGE_TIMEOUT_LONG": 4.0,
45
+ # Layout
46
+ "MAX_VISIBLE_CHILDREN": 4,
47
+ # Length caps
48
+ "MAX_NODE_LABEL_VIZ_LENGTH": 2048,
49
+ "MAX_BREADCRUMB_NODE_VIZ_LENGTH": 15,
50
+ "MAX_CHILDNODE_VIZ_LENGTH": 20,
51
+ # Colors
52
+ "COLOR_BREADCRUMBS": "dim white",
53
+ "COLOR_BREADCRUMBS_ROOT": "bold cyan",
54
+ "COLOR_BREADCRUMBS_CURRENT": "bold white",
55
+ "COLOR_CURRENT_NODE": "bold white",
56
+ "COLOR_SELECTED_CHILD": "grey23 on dim cyan",
57
+ "COLOR_CHILD": "dim cyan",
58
+ "COLOR_PAGINATION": "yellow",
59
+ "COLOR_POSITION": "dim white",
60
+ "COLOR_STATUS": "green",
61
+ "COLOR_HOTKEYS": "cyan",
62
+ "COLOR_ERROR": "red",
63
+ "COLOR_WARNING": "yellow",
64
+ "COLOR_SUCCESS": "green",
65
+ # Symbols
66
+ "SYMBOL_BULLET": "●",
67
+ "SYMBOL_BRANCH": "└─",
68
+ "SYMBOL_ROOT": "◉",
69
+ "SYMBOL_MORE_LEFT": "◀ more",
70
+ "SYMBOL_MORE_RIGHT": "more ▶",
71
+ "SYMBOL_CHILDNODE_OPENWRAP": "⦇",
72
+ "SYMBOL_CHILDNODE_CLOSEWRAP": "⦈",
73
+ "SYMBOL_BREADCRUMBNODE_OPENWRAP": "【",
74
+ "SYMBOL_BREADCRUMBNODE_CLOSEWRAP": "】",
75
+ }
76
+
77
+ CONFIG_FILENAME = "mdbub.toml"
78
+ SESSION_FILENAME = "session.json"
79
+
80
+ APPNAME = "mdbub"
81
+
82
+
83
+ def get_session_path() -> Path:
84
+ return get_xdg_config_path() / SESSION_FILENAME
85
+
86
+
87
+ def save_session(last_file: str, last_node_path: List[int]) -> None:
88
+ """Persist the last opened file and selected node path to the session file."""
89
+ session_path = get_session_path()
90
+ session_path.parent.mkdir(parents=True, exist_ok=True)
91
+ session = {
92
+ "last_file": last_file,
93
+ "last_node_path": last_node_path,
94
+ }
95
+ try:
96
+ with open(session_path, "w", encoding="utf-8") as f:
97
+ json.dump(session, f)
98
+ except Exception as e:
99
+ print(
100
+ f"[mdbub] Failed to save session: {e}",
101
+ file=sys.stderr,
102
+ )
103
+
104
+
105
+ def load_session() -> Optional[Dict[str, Any]]:
106
+ """Load the last session from the session file, if it exists."""
107
+ session_path = get_session_path()
108
+ if session_path.exists():
109
+ try:
110
+ with open(session_path, "r", encoding="utf-8") as f:
111
+ session: Dict[str, Any] = json.load(f)
112
+ return session
113
+ except Exception as e:
114
+ print(
115
+ f"[mdbub] Failed to load session: {e}",
116
+ file=sys.stderr,
117
+ )
118
+ return None
119
+
120
+
121
+ def get_xdg_config_path() -> Path:
122
+ # XDG_CONFIG_HOME or ~/.config/mdbub/
123
+ base = os.environ.get("XDG_CONFIG_HOME")
124
+ if base:
125
+ return Path(base) / APPNAME
126
+ return Path.home() / ".config" / APPNAME
127
+
128
+
129
+ def load_quickmode_config() -> Dict[str, Any]:
130
+ config_dir = get_xdg_config_path()
131
+ config_path = config_dir / CONFIG_FILENAME
132
+ config: Dict[str, Any] = QUICKMODE_DEFAULTS.copy()
133
+ if config_path.exists():
134
+ try:
135
+ user_cfg: Dict[str, Any] = toml.load(config_path)
136
+ for k, v in user_cfg.items():
137
+ if k in config:
138
+ config[k] = v
139
+ except Exception as e:
140
+ print(f"[mdbub] Failed to load quickmode config: {e}", file=sys.stderr)
141
+ return config # Always return a dict, never None
File without changes
@@ -0,0 +1,67 @@
1
+ import os
2
+ import platform
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from mdbub import BUILD_INFO, __version__
9
+ from mdbub.commands.quickmode_config import CONFIG_FILENAME, get_xdg_config_path
10
+
11
+
12
+ def main() -> None:
13
+ """Print version, build info, and config path."""
14
+ version = __version__
15
+ build_info = BUILD_INFO
16
+
17
+ # Rich output with more details
18
+ typer.echo("🧠 mdbub - Terminal mindmap tool")
19
+ typer.echo("Warning: Pre-release version - data may be lost, expect bugs!")
20
+ typer.echo(f"Version: {version}")
21
+ typer.echo(f"Build: {build_info}")
22
+ typer.echo(f"Python: {sys.version.split()[0]} ({platform.python_implementation()})")
23
+ typer.echo(
24
+ f"Platform: {platform.system()} {platform.release()} ({platform.machine()})"
25
+ )
26
+ typer.echo("")
27
+ # Installation info
28
+ try:
29
+ import mdbub
30
+
31
+ install_path = Path(mdbub.__file__).parent.parent.parent
32
+ typer.echo(f"📦 Installed at: {install_path}")
33
+ except Exception:
34
+ typer.echo("📦 Installed at: unknown")
35
+
36
+ # check if config for mdbub exists and print path other print "using defaults"
37
+ config_dir = get_xdg_config_path()
38
+ config_path = config_dir / CONFIG_FILENAME
39
+ if config_path.exists():
40
+ typer.echo(f"🔧 Config: {config_path}")
41
+ else:
42
+ typer.echo(f"🔧 Config: {config_path}, Not found (using defaults)")
43
+
44
+ # Session info
45
+ import json
46
+
47
+ from mdbub.commands.quickmode_config import SESSION_FILENAME
48
+
49
+ session_path = config_dir / SESSION_FILENAME
50
+ if session_path.exists():
51
+ try:
52
+ with open(session_path, "r") as f:
53
+ session_data = json.load(f)
54
+ last_file = session_data.get("last_file", "-")
55
+ typer.echo(f"📝 Session: {session_path}")
56
+ if last_file != "-" and not (
57
+ Path(last_file).is_file() and os.access(last_file, os.R_OK)
58
+ ):
59
+ typer.echo(f" Last file: {last_file} (missing or unreadable)")
60
+ else:
61
+ typer.echo(f" Last file: {last_file}")
62
+ # typer.echo(f" Last node path: {last_node_path}")
63
+ except Exception as e:
64
+ typer.echo(f"📝 Session: {session_path} (error reading: {e})")
65
+ else:
66
+ typer.echo(f"📝 Session: {session_path}, Not found")
67
+ typer.echo("")
mdbub/commands/view.py ADDED
@@ -0,0 +1,9 @@
1
+ import typer
2
+
3
+ app = typer.Typer()
4
+
5
+
6
+ @app.command() # type: ignore[misc]
7
+ def main(filename: str) -> None:
8
+ """Render mindmap as ASCII/Unicode tree (stub)."""
9
+ typer.echo(f"[view] Would render {filename} (stub)")
mdbub/core/__init__.py ADDED
File without changes
mdbub/core/mindmap.py ADDED
@@ -0,0 +1,241 @@
1
+ import logging
2
+ import time
3
+ from typing import Any, Dict, List, Optional, Tuple
4
+
5
+ MAX_NODE_LABEL_LENGTH = 2048 # Maximum allowed characters for a node label
6
+
7
+
8
+ class MindMapNode:
9
+ def __init__(
10
+ self,
11
+ label: str,
12
+ children: Optional[List["MindMapNode"]] = None,
13
+ color: Optional[str] = None,
14
+ icon: Optional[str] = None,
15
+ metadata: Optional[Dict[str, Any]] = None,
16
+ ) -> None:
17
+ if len(label) > MAX_NODE_LABEL_LENGTH:
18
+ logging.warning(
19
+ f"[mdbub] Node label exceeded {MAX_NODE_LABEL_LENGTH} chars and was "
20
+ "truncated."
21
+ )
22
+ label = label[:MAX_NODE_LABEL_LENGTH] + "... [truncated]"
23
+ self.label = label
24
+ self.children = children or []
25
+ self.color = color
26
+ self.icon = icon
27
+ self.metadata = metadata or {}
28
+ self.parent: Optional[
29
+ "MindMapNode"
30
+ ] = None # Reference to parent node, useful for navigation
31
+
32
+ def add_child(self, child: "MindMapNode") -> None:
33
+ self.children.append(child)
34
+ child.parent = self # Set parent reference
35
+
36
+ def add_tag(self, tag: str) -> None:
37
+ """Add a tag to this node's metadata."""
38
+ if "tags" not in self.metadata:
39
+ self.metadata["tags"] = []
40
+ if isinstance(self.metadata["tags"], list) and tag not in self.metadata["tags"]:
41
+ self.metadata["tags"].append(tag)
42
+
43
+ def remove_tag(self, tag: str) -> None:
44
+ """Remove a tag from this node's metadata."""
45
+ if "tags" in self.metadata and isinstance(self.metadata["tags"], list):
46
+ if tag in self.metadata["tags"]:
47
+ self.metadata["tags"].remove(tag)
48
+ # Clean up empty tags list
49
+ if not self.metadata["tags"]:
50
+ del self.metadata["tags"]
51
+
52
+ def get_tags(self) -> List[str]:
53
+ """Get all tags for this node."""
54
+ if "tags" in self.metadata and isinstance(self.metadata["tags"], list):
55
+ return self.metadata["tags"]
56
+ return []
57
+
58
+ def set_metadata(self, key: str, value: Any) -> None:
59
+ """Set a metadata key-value pair."""
60
+ self.metadata[key] = value
61
+
62
+ def get_metadata(self, key: str, default: Any = None) -> Any:
63
+ """Get a metadata value by key."""
64
+ return self.metadata.get(key, default)
65
+
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ result: Dict[str, Any] = {
68
+ "label": self.label,
69
+ "children": [child.to_dict() for child in self.children],
70
+ }
71
+ # Only include non-empty values
72
+ if self.color:
73
+ result["color"] = self.color
74
+ if self.icon:
75
+ result["icon"] = self.icon
76
+ if self.metadata:
77
+ result["metadata"] = self.metadata
78
+ return result
79
+
80
+ @staticmethod
81
+ def from_dict(data: Dict[str, Any]) -> "MindMapNode":
82
+ node = MindMapNode(
83
+ label=data["label"],
84
+ color=data.get("color"),
85
+ icon=data.get("icon"),
86
+ metadata=data.get("metadata", {}),
87
+ )
88
+ for child_data in data.get("children", []):
89
+ child = MindMapNode.from_dict(child_data)
90
+ node.add_child(child)
91
+ return node
92
+
93
+
94
+ def parse_markdown_to_mindmap(md: str) -> MindMapNode:
95
+ """Parse extended markdown into a MindMapNode tree with metadata.
96
+
97
+ Metadata format supports:
98
+ - Tags in the format: #tag1 #tag2
99
+ - Key-value pairs: @key=value
100
+ """
101
+ start_time = time.time()
102
+ lines = [
103
+ line.rstrip()
104
+ for line in md.splitlines()
105
+ if line.strip() and not line.startswith("<!-- mdbub-format")
106
+ ]
107
+ # NOTE: Loosen annotation for release: mypy cannot handle recursive tuple type here.
108
+ top_nodes: List[Any] = [] # List of (label, metadata, children_tuples, indent)
109
+ stack: List[Any] = [] # Stack for parent tracking
110
+
111
+ logging.info(f"[mdbub] Parsing {len(lines)} lines from markdown...")
112
+ for line in lines:
113
+ if not line.lstrip().startswith("-"):
114
+ continue
115
+
116
+ indent = len(line) - len(line.lstrip())
117
+ content = line.lstrip()[2:].strip()
118
+
119
+ # Parse metadata from the label
120
+ label, metadata = _parse_node_metadata(content)
121
+
122
+ if indent == 0:
123
+ top_nodes.append((label, metadata, [], 0))
124
+ stack = [top_nodes[-1]]
125
+ else:
126
+ # Attach as child to closest parent with lower indent
127
+ while stack and stack[-1][3] >= indent:
128
+ stack.pop()
129
+ if stack:
130
+ stack[-1][2].append((label, metadata, [], indent))
131
+ stack.append(stack[-1][2][-1])
132
+
133
+ def build_tree(
134
+ node_tuple: Any, # TODO: Recursive tuple type; loosened for mypy compatibility
135
+ depth: int = 0,
136
+ ) -> MindMapNode:
137
+ label, metadata, children_tuples, _ = node_tuple
138
+ if len(label) > MAX_NODE_LABEL_LENGTH:
139
+ logging.warning(
140
+ f"[mdbub] Node label exceeded {MAX_NODE_LABEL_LENGTH} chars and was truncated during parsing."
141
+ )
142
+ label = label[:MAX_NODE_LABEL_LENGTH] + "... [truncated]"
143
+ if depth > 100:
144
+ logging.error(f"[mdbub] Maximum recursion depth reached at node '{label}'")
145
+ return MindMapNode(label, metadata=metadata)
146
+
147
+ node = MindMapNode(label, metadata=metadata)
148
+
149
+ # Process tags if present
150
+ if "tags" in metadata:
151
+ # Tags are already in the metadata dictionary
152
+ pass
153
+
154
+ for child_tuple in children_tuples:
155
+ node.add_child(build_tree(child_tuple, depth=depth + 1))
156
+ return node
157
+
158
+ if len(top_nodes) == 1:
159
+ result = build_tree(top_nodes[0])
160
+ elif len(top_nodes) > 1:
161
+ root = MindMapNode("SYNTHETIC ROOT")
162
+ for node_tuple in top_nodes:
163
+ root.add_child(build_tree(node_tuple))
164
+
165
+ def warn_multi_root() -> None:
166
+ root._multi_root_warning = True # type: ignore[attr-defined] # Custom attribute to signal warning
167
+
168
+ warn_multi_root()
169
+ result = root
170
+ else:
171
+ result = MindMapNode("") # Empty mindmap
172
+ elapsed = time.time() - start_time
173
+ logging.info(f"[mdbub] Mindmap parse complete in {elapsed:.3f} seconds.")
174
+ return result
175
+
176
+
177
+ def _parse_node_metadata(content: str) -> Tuple[str, Dict[str, Any]]:
178
+ """Parse node metadata from content string.
179
+
180
+ Returns:
181
+ tuple: (label, metadata_dict)
182
+ """
183
+ import re
184
+
185
+ # Initialize metadata dictionary
186
+ metadata = {}
187
+
188
+ # Extract tags (format: #tag)
189
+ tags = re.findall(r"\s#([\w-]+)", content)
190
+ if tags:
191
+ metadata["tags"] = tags
192
+ # Remove tags from content
193
+ for tag in tags:
194
+ content = re.sub(r"\s#" + tag + "\b", "", content)
195
+
196
+ # Extract key-value pairs (format: @key=value)
197
+ kv_pairs = re.findall(r"\s@([\w-]+)=([^\s]+)", content)
198
+ for key, value in kv_pairs:
199
+ metadata[key] = value
200
+ # Remove key-value pair from content
201
+ content = re.sub(r"\s@" + key + "=" + value + "\b", "", content)
202
+
203
+ # Clean up any extra whitespace
204
+ label = content.strip()
205
+
206
+ return label, metadata
207
+
208
+
209
+ def mindmap_to_markdown(node: "MindMapNode", level: int = 0) -> str:
210
+ """Serialize MindMapNode tree to markdown bullets with metadata."""
211
+ indent = " " * level
212
+ lines = []
213
+ # Build node content with metadata
214
+ content = node.label
215
+
216
+ # Only add tags that are NOT already inline in the label
217
+ label_lower = node.label.lower()
218
+ if "tags" in node.metadata and node.metadata["tags"]:
219
+ tags = node.metadata["tags"]
220
+ for tag in tags:
221
+ tag_str = f"#{tag.lower()}"
222
+ if tag_str not in label_lower:
223
+ content += f" #{tag}"
224
+
225
+ # Only add metadata that is NOT already inline in the label
226
+ for key, value in node.metadata.items():
227
+ if key != "tags" and value is not None:
228
+ if isinstance(value, (list, dict)):
229
+ continue # Skip complex structures for now
230
+ meta_str = f"@{key.lower()}={str(value).lower()}"
231
+ if meta_str not in label_lower:
232
+ content += f" @{key}={value}"
233
+
234
+ # Emit the current node with its metadata
235
+ lines.append(f"{indent}- {content}")
236
+
237
+ # Process all children recursively
238
+ for child in node.children:
239
+ lines.append(mindmap_to_markdown(child, level + 1))
240
+
241
+ return "\n".join([line for line in lines if line])
@@ -0,0 +1,33 @@
1
+ from typing import List
2
+
3
+ from .mindmap import MindMapNode
4
+
5
+
6
+ def get_node_by_path(root: MindMapNode, path: List[int]) -> MindMapNode:
7
+ node = root
8
+ for idx in path:
9
+ node = node.children[idx]
10
+ return node
11
+
12
+
13
+ def get_breadcrumbs(root: MindMapNode, path: List[int]) -> List[str]:
14
+ node = root
15
+ labels = [node.label]
16
+ for idx in path:
17
+ node = node.children[idx]
18
+ labels.append(node.label)
19
+ return labels
20
+
21
+
22
+ def add_child(node: MindMapNode, label: str = "New Child") -> MindMapNode:
23
+ child = MindMapNode(label)
24
+ node.children.append(child)
25
+ return child
26
+
27
+
28
+ def add_sibling(
29
+ parent: MindMapNode, idx: int, label: str = "New Sibling"
30
+ ) -> MindMapNode:
31
+ sibling = MindMapNode(label)
32
+ parent.children.insert(idx + 1, sibling)
33
+ return sibling
mdbub/symbols.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ # WRAPPER SYMBOL OPTIONS
3
+ # -------------------------------------------------------------------------------------
4
+ # These symbols can be used for SYMBOL_CHILDNODE_OPENWRAP, SYMBOL_CHILDNODE_CLOSEWRAP,
5
+ # SYMBOL_BREADCRUMBNODE_OPENWRAP, and SYMBOL_BREADCRUMBNODE_CLOSEWRAP
6
+ #
7
+ # TRADITIONAL BRACKETS
8
+ # -------------------------------------------------------------------------------------
9
+ # Parentheses: ( )
10
+ # Square brackets: [ ]
11
+ # Curly braces: { }
12
+ # Angle brackets: < >
13
+ #
14
+ # ASIAN STYLE BRACKETS
15
+ # -------------------------------------------------------------------------------------
16
+ # Japanese corner brackets: 「 」
17
+ # White corner brackets: 『 』
18
+ # Black lenticular brackets: 【 】
19
+ # White lenticular brackets: 〖 〗
20
+ # Tortoise shell brackets: 〔 〕
21
+ # Double angle brackets: 《 》
22
+ # CJK brackets: 〈 〉
23
+ # Fullwidth brackets: ( )
24
+ # Fullwidth curly braces: { }
25
+ # Fullwidth square brackets: [ ]
26
+ #
27
+ # MATHEMATICAL AND SPECIAL BRACKETS
28
+ # -------------------------------------------------------------------------------------
29
+ # Mathematical angle brackets: ⟨ ⟩
30
+ # Double square brackets: ⟦ ⟧
31
+ # Floor brackets: ⌊ ⌋
32
+ # Ceiling brackets: ⌈ ⌉
33
+ # Z notation: ⦇ ⦈
34
+ # Angle with dot: ⦑ ⦒
35
+ # Square with ticks: ⦋ ⦌
36
+ # Double-struck brackets: ⟦ ⟧
37
+ #
38
+ # DECORATIVE SYMBOLS (PAIRED)
39
+ # -------------------------------------------------------------------------------------
40
+ # Stars: ✧ ✧
41
+ # Black stars: ★ ★
42
+ # Arrows: → ←
43
+ # Heavy arrows: ➤ ➤
44
+ # Triangle arrows: ▶ ◀
45
+ # Dots/bullets: • •
46
+ # Diamonds: ◆ ◆
47
+ # White diamonds: ◇ ◇
48
+ # Circles: ○ ○
49
+ # Black circles: ● ●
50
+ # Flowers: ✿ ✿
51
+ # Cherry blossoms: ❀ ❀
52
+ # Sparkles: ✨ ✨
53
+ # Quotes: " "
54
+ # Typographic quotes: " "
55
+ # French quotes (guillemets): « »
56
+ # Single guillemets: ‹ ›
57
+ #
58
+ # REVERSED BRACKETS (RIGHT-TO-LEFT)
59
+ # -------------------------------------------------------------------------------------
60
+ # Reversed Japanese corner: 」 「
61
+ # Reversed white corner: 』 『
62
+ # Reversed double angle: 》 《
63
+ # Reversed black lenticular: 】 【
64
+ # Reversed white lenticular: 〗 〖
65
+ # Reversed square: ] [
66
+ # Reversed curly: } {
67
+ # Reversed parentheses: ) (
68
+ """