docmost-cli 0.4.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.
Files changed (56) hide show
  1. docmost_cli/__init__.py +5 -0
  2. docmost_cli/__main__.py +18 -0
  3. docmost_cli/api/__init__.py +5 -0
  4. docmost_cli/api/attachments.py +30 -0
  5. docmost_cli/api/auth.py +202 -0
  6. docmost_cli/api/client.py +296 -0
  7. docmost_cli/api/comments.py +103 -0
  8. docmost_cli/api/pages.py +530 -0
  9. docmost_cli/api/pagination.py +94 -0
  10. docmost_cli/api/search.py +40 -0
  11. docmost_cli/api/spaces.py +141 -0
  12. docmost_cli/api/users.py +25 -0
  13. docmost_cli/api/workspace.py +43 -0
  14. docmost_cli/cli/__init__.py +3 -0
  15. docmost_cli/cli/attachment.py +30 -0
  16. docmost_cli/cli/comment.py +83 -0
  17. docmost_cli/cli/config_cmd.py +143 -0
  18. docmost_cli/cli/main.py +133 -0
  19. docmost_cli/cli/page.py +382 -0
  20. docmost_cli/cli/search.py +33 -0
  21. docmost_cli/cli/space.py +57 -0
  22. docmost_cli/cli/sync_cmd.py +122 -0
  23. docmost_cli/cli/user.py +25 -0
  24. docmost_cli/cli/workspace.py +40 -0
  25. docmost_cli/config/__init__.py +23 -0
  26. docmost_cli/config/settings.py +23 -0
  27. docmost_cli/config/store.py +160 -0
  28. docmost_cli/convert/__init__.py +3 -0
  29. docmost_cli/convert/prosemirror_to_md.py +300 -0
  30. docmost_cli/models/__init__.py +3 -0
  31. docmost_cli/models/common.py +3 -0
  32. docmost_cli/output/__init__.py +17 -0
  33. docmost_cli/output/formatter.py +85 -0
  34. docmost_cli/output/tree.py +66 -0
  35. docmost_cli/py.typed +0 -0
  36. docmost_cli/sync/__init__.py +57 -0
  37. docmost_cli/sync/diff.py +156 -0
  38. docmost_cli/sync/frontmatter.py +152 -0
  39. docmost_cli/sync/manifest.py +195 -0
  40. docmost_cli/sync/pull.py +158 -0
  41. docmost_cli/sync/push.py +374 -0
  42. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-attachment.1 +57 -0
  43. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-comment.1 +92 -0
  44. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-config.1 +127 -0
  45. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-page.1 +412 -0
  46. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-search.1 +90 -0
  47. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-space.1 +111 -0
  48. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-sync.1 +206 -0
  49. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-user.1 +39 -0
  50. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-workspace.1 +68 -0
  51. docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli.1 +301 -0
  52. docmost_cli-0.4.0.dist-info/METADATA +241 -0
  53. docmost_cli-0.4.0.dist-info/RECORD +56 -0
  54. docmost_cli-0.4.0.dist-info/WHEEL +4 -0
  55. docmost_cli-0.4.0.dist-info/entry_points.txt +2 -0
  56. docmost_cli-0.4.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,23 @@
1
+ """Pydantic settings model for Docmost CLI configuration.
2
+
3
+ Resolution order: CLI flags > env vars > config file > defaults.
4
+ The config file is loaded by store.py and merged separately.
5
+ """
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ __all__ = ["DocmostSettings"]
10
+
11
+
12
+ class DocmostSettings(BaseSettings):
13
+ """Typed settings for a Docmost CLI session."""
14
+
15
+ model_config = SettingsConfigDict(
16
+ env_prefix="DOCMOST_",
17
+ )
18
+
19
+ url: str | None = None
20
+ api_key: str | None = None
21
+ email: str | None = None
22
+ password: str | None = None
23
+ profile: str = "default"
@@ -0,0 +1,160 @@
1
+ """Config file read/write and settings factory.
2
+
3
+ Handles TOML config file at ~/.config/docmost-cli/config.toml
4
+ and session cache at ~/.cache/docmost-cli/session.json.
5
+ """
6
+
7
+ import os
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+ import tomli_w
12
+
13
+ from docmost_cli.config.settings import DocmostSettings
14
+
15
+ __all__ = [
16
+ "get_cache_dir",
17
+ "get_config_path",
18
+ "load_settings",
19
+ "read_config",
20
+ "read_profile",
21
+ "set_config_value",
22
+ "write_config",
23
+ ]
24
+
25
+ APP_NAME = "docmost-cli"
26
+
27
+
28
+ def get_config_path(override: str | None = None) -> Path:
29
+ """Return the config file path.
30
+
31
+ Args:
32
+ override: Explicit path to use instead of the default.
33
+
34
+ Returns:
35
+ Path to the config TOML file.
36
+ """
37
+ if override:
38
+ return Path(override)
39
+ config_home = os.environ.get("XDG_CONFIG_HOME")
40
+ base = Path(config_home) if config_home else Path.home() / ".config"
41
+ return base / APP_NAME / "config.toml"
42
+
43
+
44
+ def get_cache_dir() -> Path:
45
+ """Return the cache directory path for session tokens."""
46
+ cache_home = os.environ.get("XDG_CACHE_HOME")
47
+ base = Path(cache_home) if cache_home else Path.home() / ".cache"
48
+ return base / APP_NAME
49
+
50
+
51
+ def read_config(config_path: Path | None = None) -> dict[str, dict[str, str]]:
52
+ """Read the full TOML config, returning all profiles.
53
+
54
+ Args:
55
+ config_path: Path to config file. Uses default if None.
56
+
57
+ Returns:
58
+ Dict of profile name → dict of key-value pairs.
59
+ """
60
+ path = config_path or get_config_path()
61
+ if not path.exists():
62
+ return {}
63
+ with open(path, "rb") as f:
64
+ return tomllib.load(f)
65
+
66
+
67
+ def read_profile(profile: str = "default", config_path: Path | None = None) -> dict[str, str]:
68
+ """Read a specific profile from the config file.
69
+
70
+ Args:
71
+ profile: Profile name to read.
72
+ config_path: Path to config file. Uses default if None.
73
+
74
+ Returns:
75
+ Dict of config values for the profile. Empty dict if not found.
76
+ """
77
+ config = read_config(config_path)
78
+ return config.get(profile, {})
79
+
80
+
81
+ def write_config(config: dict[str, dict[str, str]], config_path: Path | None = None) -> None:
82
+ """Write the full config dict to the TOML file.
83
+
84
+ Creates parent directories if needed. Writes atomically via temp file.
85
+
86
+ Args:
87
+ config: Full config dict (profile name → key-value pairs).
88
+ config_path: Path to config file. Uses default if None.
89
+ """
90
+ path = config_path or get_config_path()
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ tmp_path = path.with_suffix(".tmp")
93
+ with open(tmp_path, "wb") as f:
94
+ tomli_w.dump(config, f)
95
+ tmp_path.replace(path)
96
+
97
+
98
+ def set_config_value(
99
+ key: str,
100
+ value: str,
101
+ profile: str = "default",
102
+ config_path: Path | None = None,
103
+ ) -> None:
104
+ """Set a single key in a profile, creating profile/file if needed.
105
+
106
+ Args:
107
+ key: Config key to set.
108
+ value: Value to set.
109
+ profile: Profile to update.
110
+ config_path: Path to config file. Uses default if None.
111
+ """
112
+ config = read_config(config_path)
113
+ if profile not in config:
114
+ config[profile] = {}
115
+ config[profile][key] = value
116
+ write_config(config, config_path)
117
+
118
+
119
+ def load_settings(
120
+ profile: str = "default",
121
+ config_path: Path | None = None,
122
+ cli_overrides: dict[str, str] | None = None,
123
+ ) -> DocmostSettings:
124
+ """Load settings with full priority chain.
125
+
126
+ Priority: CLI overrides > env vars > config file > defaults.
127
+
128
+ In pydantic-settings v2, init kwargs beat env vars. So we construct
129
+ with no kwargs first (picks up env vars), then fill in config file
130
+ values only for fields that are still unset, then apply CLI overrides.
131
+
132
+ Args:
133
+ profile: Config profile name to load.
134
+ config_path: Path to config file. Uses default if None.
135
+ cli_overrides: Dict of CLI flag overrides (highest priority).
136
+
137
+ Returns:
138
+ Fully resolved DocmostSettings instance.
139
+ """
140
+ file_values = read_profile(profile, config_path)
141
+
142
+ # Step 1: Construct with no init kwargs — picks up env vars.
143
+ settings = DocmostSettings()
144
+
145
+ # Step 2: Fill in config file values only where env didn't set a value.
146
+ updates: dict[str, str] = {}
147
+ for key, value in file_values.items():
148
+ if key in DocmostSettings.model_fields and getattr(settings, key) is None:
149
+ updates[key] = value
150
+ updates["profile"] = profile
151
+ if updates:
152
+ settings = settings.model_copy(update=updates)
153
+
154
+ # Step 3: CLI overrides beat everything.
155
+ if cli_overrides:
156
+ non_none = {k: v for k, v in cli_overrides.items() if v is not None}
157
+ if non_none:
158
+ settings = settings.model_copy(update=non_none)
159
+
160
+ return settings
@@ -0,0 +1,3 @@
1
+ """ProseMirror ↔ Markdown conversion."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,300 @@
1
+ """ProseMirror JSON → Markdown converter.
2
+
3
+ Handles all Docmost node types and marks, converting ProseMirror
4
+ document trees into GitHub-Flavored Markdown.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ __all__ = ["convert_to_markdown"]
10
+
11
+
12
+ class ProseMirrorConverter:
13
+ """Recursive ProseMirror node walker that produces Markdown output."""
14
+
15
+ def __init__(self) -> None:
16
+ self._indent = 0
17
+ self._list_type: list[str] = [] # stack: "bullet", "ordered", "task"
18
+ self._ordered_counter: list[int] = [] # counter per ordered list level
19
+
20
+ def convert(self, doc: dict[str, Any]) -> str:
21
+ """Convert a ProseMirror document to Markdown.
22
+
23
+ Args:
24
+ doc: ProseMirror document dict (root node with type "doc").
25
+
26
+ Returns:
27
+ Markdown string ending with a single newline.
28
+ """
29
+ if not isinstance(doc, dict):
30
+ return ""
31
+ result = self._walk_node(doc)
32
+ return result.rstrip("\n") + "\n"
33
+
34
+ # -- Dispatch -----------------------------------------------------------
35
+
36
+ def _walk_node(self, node: dict[str, Any]) -> str:
37
+ """Dispatch to the handler for a node's type."""
38
+ node_type = node.get("type", "")
39
+ handler = getattr(self, f"_node_{node_type}", None)
40
+ if handler:
41
+ return handler(node)
42
+ # Unknown node: try to extract content recursively
43
+ return self._walk_children(node)
44
+
45
+ def _walk_children(self, node: dict[str, Any]) -> str:
46
+ """Walk all child nodes and concatenate their output."""
47
+ return "".join(self._walk_node(child) for child in node.get("content", []))
48
+
49
+ # -- Block nodes --------------------------------------------------------
50
+
51
+ def _node_doc(self, node: dict[str, Any]) -> str:
52
+ return self._walk_children(node)
53
+
54
+ def _node_paragraph(self, node: dict[str, Any]) -> str:
55
+ text = self._render_inline(node.get("content", []))
56
+ # Inside a list item, don't add double newline
57
+ if self._indent > 0:
58
+ return text
59
+ return text + "\n\n"
60
+
61
+ def _node_heading(self, node: dict[str, Any]) -> str:
62
+ level = node.get("attrs", {}).get("level", 1)
63
+ text = self._render_inline(node.get("content", []))
64
+ return "#" * level + " " + text + "\n\n"
65
+
66
+ def _node_bulletList(self, node: dict[str, Any]) -> str:
67
+ return self._handle_list(node, "bullet")
68
+
69
+ def _node_orderedList(self, node: dict[str, Any]) -> str:
70
+ return self._handle_list(node, "ordered")
71
+
72
+ def _node_taskList(self, node: dict[str, Any]) -> str:
73
+ return self._handle_list(node, "task")
74
+
75
+ def _handle_list(self, node: dict[str, Any], list_type: str) -> str:
76
+ self._list_type.append(list_type)
77
+ self._indent += 1
78
+ if list_type == "ordered":
79
+ self._ordered_counter.append(0)
80
+
81
+ result = ""
82
+ for child in node.get("content", []):
83
+ if child.get("type") in ("listItem", "taskItem"):
84
+ result += self._handle_list_item(child)
85
+
86
+ self._indent -= 1
87
+ self._list_type.pop()
88
+ if list_type == "ordered":
89
+ self._ordered_counter.pop()
90
+
91
+ # Add trailing newline after top-level list
92
+ if self._indent == 0:
93
+ result += "\n"
94
+ return result
95
+
96
+ def _node_listItem(self, node: dict[str, Any]) -> str:
97
+ return self._handle_list_item(node)
98
+
99
+ def _node_taskItem(self, node: dict[str, Any]) -> str:
100
+ return self._handle_list_item(node)
101
+
102
+ def _handle_list_item(self, node: dict[str, Any]) -> str:
103
+ indent = " " * (self._indent - 1)
104
+ current_type = self._list_type[-1] if self._list_type else "bullet"
105
+
106
+ # Determine prefix
107
+ if current_type == "task":
108
+ checked = node.get("attrs", {}).get("checked", False)
109
+ prefix = "- [x] " if checked else "- [ ] "
110
+ elif current_type == "ordered":
111
+ self._ordered_counter[-1] += 1
112
+ prefix = f"{self._ordered_counter[-1]}. "
113
+ else:
114
+ prefix = "- "
115
+
116
+ # Separate inline content from nested lists
117
+ text_parts: list[str] = []
118
+ nested_parts: list[str] = []
119
+ for child in node.get("content", []):
120
+ child_type = child.get("type", "")
121
+ if child_type in ("bulletList", "orderedList", "taskList"):
122
+ nested_parts.append(self._walk_node(child))
123
+ elif child_type == "paragraph":
124
+ text_parts.append(self._render_inline(child.get("content", [])))
125
+ else:
126
+ text_parts.append(self._walk_node(child))
127
+
128
+ text = " ".join(t.strip() for t in text_parts if t.strip())
129
+ result = indent + prefix + text + "\n"
130
+ result += "".join(nested_parts)
131
+ return result
132
+
133
+ def _node_codeBlock(self, node: dict[str, Any]) -> str:
134
+ lang = node.get("attrs", {}).get("language", "")
135
+ code = self._render_inline(node.get("content", []))
136
+ return f"```{lang}\n{code}\n```\n\n"
137
+
138
+ def _node_blockquote(self, node: dict[str, Any]) -> str:
139
+ content = self._walk_children(node)
140
+ lines = content.rstrip("\n").split("\n")
141
+ quoted = "\n".join("> " + line for line in lines)
142
+ return quoted + "\n\n"
143
+
144
+ def _node_horizontalRule(self, node: dict[str, Any]) -> str:
145
+ return "---\n\n"
146
+
147
+ def _node_table(self, node: dict[str, Any]) -> str:
148
+ rows = node.get("content", [])
149
+ if not rows:
150
+ return ""
151
+
152
+ output_lines: list[str] = []
153
+ for i, row in enumerate(rows):
154
+ cells = row.get("content", [])
155
+ cell_texts = []
156
+ for cell in cells:
157
+ # Cell content is usually paragraphs
158
+ text = self._walk_children(cell).strip().replace("\n", " ")
159
+ # Escape pipes in cell content
160
+ text = text.replace("|", "\\|")
161
+ cell_texts.append(text)
162
+
163
+ output_lines.append("| " + " | ".join(cell_texts) + " |")
164
+
165
+ # Separator after header row
166
+ if i == 0:
167
+ output_lines.append("|" + "|".join("---" for _ in cell_texts) + "|")
168
+
169
+ return "\n".join(output_lines) + "\n\n"
170
+
171
+ def _node_image(self, node: dict[str, Any]) -> str:
172
+ attrs = node.get("attrs", {})
173
+ alt = attrs.get("alt", "")
174
+ src = attrs.get("src", "")
175
+ title = attrs.get("title", "")
176
+ if title:
177
+ return f'![{alt}]({src} "{title}")\n\n'
178
+ return f"![{alt}]({src})\n\n"
179
+
180
+ def _node_hardBreak(self, node: dict[str, Any]) -> str:
181
+ return "\n"
182
+
183
+ def _node_callout(self, node: dict[str, Any]) -> str:
184
+ callout_type = node.get("attrs", {}).get("type", "info")
185
+ content = self._walk_children(node).strip()
186
+ return f"> **{callout_type}**: {content}\n\n"
187
+
188
+ def _node_details(self, node: dict[str, Any]) -> str:
189
+ summary = ""
190
+ body = ""
191
+ for child in node.get("content", []):
192
+ if child.get("type") == "detailsSummary":
193
+ summary = self._render_inline(child.get("content", []))
194
+ elif child.get("type") == "detailsContent":
195
+ body = self._walk_children(child).strip()
196
+ return f"<details>\n<summary>{summary.strip()}</summary>\n\n{body}\n</details>\n\n"
197
+
198
+ def _node_detailsSummary(self, node: dict[str, Any]) -> str:
199
+ return self._render_inline(node.get("content", []))
200
+
201
+ def _node_detailsContent(self, node: dict[str, Any]) -> str:
202
+ return self._walk_children(node)
203
+
204
+ def _node_mathBlock(self, node: dict[str, Any]) -> str:
205
+ content = self._render_inline(node.get("content", []))
206
+ return f"$$\n{content.strip()}\n$$\n\n"
207
+
208
+ def _node_embed(self, node: dict[str, Any]) -> str:
209
+ src = node.get("attrs", {}).get("src", "")
210
+ return f"[Embed: {src}]\n\n"
211
+
212
+ def _node_drawio(self, node: dict[str, Any]) -> str:
213
+ return "[Diagram: drawio]\n\n"
214
+
215
+ def _node_excalidraw(self, node: dict[str, Any]) -> str:
216
+ return "[Diagram: excalidraw]\n\n"
217
+
218
+ # -- Inline rendering ---------------------------------------------------
219
+
220
+ def _render_inline(self, content: list[dict[str, Any]]) -> str:
221
+ """Render inline nodes (text with marks, hard breaks, etc.)."""
222
+ parts: list[str] = []
223
+ for node in content or []:
224
+ node_type = node.get("type", "")
225
+ if node_type == "text":
226
+ text = node.get("text", "")
227
+ marks = node.get("marks", [])
228
+ parts.append(self._apply_marks(text, marks))
229
+ elif node_type == "hardBreak":
230
+ parts.append("\n")
231
+ elif node_type == "image":
232
+ attrs = node.get("attrs", {})
233
+ alt = attrs.get("alt", "")
234
+ src = attrs.get("src", "")
235
+ parts.append(f"![{alt}]({src})")
236
+ else:
237
+ # Other inline nodes
238
+ parts.append(self._walk_node(node))
239
+ return "".join(parts)
240
+
241
+ def _apply_marks(self, text: str, marks: list[dict[str, Any]]) -> str:
242
+ """Wrap text in Markdown mark delimiters."""
243
+ if not marks:
244
+ return text
245
+
246
+ # Sort by precedence: outermost wraps first
247
+ precedence = {
248
+ "link": 0,
249
+ "bold": 1,
250
+ "italic": 2,
251
+ "strike": 3,
252
+ "code": 4,
253
+ "highlight": 5,
254
+ "underline": 6,
255
+ }
256
+ sorted_marks = sorted(
257
+ marks,
258
+ key=lambda m: precedence.get(m.get("type", ""), 99),
259
+ reverse=True,
260
+ )
261
+
262
+ for mark in sorted_marks:
263
+ text = self._apply_single_mark(text, mark)
264
+ return text
265
+
266
+ @staticmethod
267
+ def _apply_single_mark(text: str, mark: dict[str, Any]) -> str:
268
+ """Apply a single mark to text."""
269
+ mark_type = mark.get("type", "")
270
+ attrs = mark.get("attrs", {})
271
+
272
+ if mark_type == "bold":
273
+ return f"**{text}**"
274
+ if mark_type == "italic":
275
+ return f"*{text}*"
276
+ if mark_type == "code":
277
+ return f"`{text}`"
278
+ if mark_type == "strike":
279
+ return f"~~{text}~~"
280
+ if mark_type == "link":
281
+ href = attrs.get("href", "")
282
+ return f"[{text}]({href})"
283
+ if mark_type == "highlight":
284
+ return f"=={text}=="
285
+ if mark_type == "underline":
286
+ return f"<u>{text}</u>"
287
+ # Unknown mark: passthrough
288
+ return text
289
+
290
+
291
+ def convert_to_markdown(doc: dict[str, Any]) -> str:
292
+ """Convert a ProseMirror document to Markdown.
293
+
294
+ Args:
295
+ doc: ProseMirror document dict.
296
+
297
+ Returns:
298
+ Markdown string.
299
+ """
300
+ return ProseMirrorConverter().convert(doc)
@@ -0,0 +1,3 @@
1
+ """Pydantic data models for Docmost API types."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,3 @@
1
+ """Shared data models used across the project."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,17 @@
1
+ """Output formatting helpers enforcing stdout/stderr separation."""
2
+
3
+ from docmost_cli.output.formatter import (
4
+ print_content,
5
+ print_content_with_meta,
6
+ print_error,
7
+ print_key_value,
8
+ print_result,
9
+ )
10
+
11
+ __all__ = [
12
+ "print_content",
13
+ "print_content_with_meta",
14
+ "print_error",
15
+ "print_key_value",
16
+ "print_result",
17
+ ]
@@ -0,0 +1,85 @@
1
+ """Output dispatch: stdout/stderr separation for all command types."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any, NoReturn
6
+
7
+ from rich.console import Console
8
+
9
+ __all__ = [
10
+ "print_content",
11
+ "print_content_with_meta",
12
+ "print_error",
13
+ "print_key_value",
14
+ "print_result",
15
+ "print_table",
16
+ ]
17
+
18
+ _err_console = Console(stderr=True)
19
+
20
+
21
+ def print_content(content: str) -> None:
22
+ """Print content (Markdown) directly to stdout."""
23
+ sys.stdout.write(content)
24
+
25
+
26
+ def print_content_with_meta(content: str, meta: dict[str, Any]) -> None:
27
+ """Print YAML frontmatter + Markdown content to stdout."""
28
+ lines = ["---"]
29
+ for key, value in meta.items():
30
+ lines.append(f"{key}: {value}")
31
+ lines.append("---")
32
+ lines.append("")
33
+ sys.stdout.write("\n".join(lines))
34
+ sys.stdout.write(content)
35
+
36
+
37
+ def print_key_value(data: dict[str, Any], key_style: str = "bold") -> None:
38
+ """Print key-value pairs for single-item info display.
39
+
40
+ Args:
41
+ data: Dictionary of key-value pairs to display.
42
+ key_style: Rich style string for keys column.
43
+ """
44
+ from rich.table import Table
45
+
46
+ table = Table(show_header=False, box=None, padding=(0, 2))
47
+ table.add_column(style=key_style)
48
+ table.add_column()
49
+ for key, value in data.items():
50
+ if value is not None and value != "":
51
+ table.add_row(str(key), str(value))
52
+
53
+ console = Console()
54
+ console.print(table)
55
+
56
+
57
+ def print_table(rows: list[dict[str, Any]], columns: list[str], json_mode: bool = False) -> None:
58
+ """Print as rich table or JSON array depending on mode."""
59
+ if json_mode:
60
+ filtered = [{col: row.get(col) for col in columns} for row in rows]
61
+ sys.stdout.write(json.dumps(filtered, indent=2, default=str) + "\n")
62
+ return
63
+
64
+ from rich.table import Table
65
+
66
+ table = Table()
67
+ for col in columns:
68
+ table.add_column(col)
69
+ for row in rows:
70
+ table.add_row(*(str(row.get(col, "")) for col in columns))
71
+
72
+ console = Console()
73
+ console.print(table)
74
+
75
+
76
+ def print_result(resource_id: str, message: str) -> None:
77
+ """Print resource ID to stdout, confirmation to stderr."""
78
+ sys.stdout.write(resource_id + "\n")
79
+ _err_console.print(message)
80
+
81
+
82
+ def print_error(message: str, exit_code: int = 1) -> NoReturn:
83
+ """Print error to stderr and exit with given code."""
84
+ _err_console.print(f"[red]Error:[/red] {message}")
85
+ raise SystemExit(exit_code)
@@ -0,0 +1,66 @@
1
+ """Tree view rendering for hierarchical page lists.
2
+
3
+ Renders nested page structures using Unicode box-drawing characters.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+
10
+ __all__ = ["print_tree"]
11
+
12
+ MAX_TITLE_LEN = 60
13
+ _console = Console()
14
+
15
+
16
+ def print_tree(pages: list[dict[str, Any]]) -> None:
17
+ """Render a nested page tree using box-drawing characters.
18
+
19
+ Expects pages with nested 'children' arrays, as returned by
20
+ POST /pages/sidebar-pages.
21
+
22
+ Args:
23
+ pages: List of page dicts, each may have a 'children' key.
24
+ """
25
+ for i, page in enumerate(pages):
26
+ is_last = i == len(pages) - 1
27
+ _print_node(page, "", is_last)
28
+
29
+
30
+ def _print_node(
31
+ page: dict[str, Any],
32
+ prefix: str,
33
+ is_last: bool,
34
+ ) -> None:
35
+ """Print a single tree node and recurse into children."""
36
+ connector = "\\-- " if is_last else "+-- "
37
+
38
+ icon = page.get("icon", "") or ""
39
+ title = page.get("title", page.get("id", "???"))
40
+
41
+ # Truncate long titles
42
+ if len(title) > MAX_TITLE_LEN:
43
+ title = title[: MAX_TITLE_LEN - 3] + "..."
44
+
45
+ # Rich uses LegacyWindowsRenderer which bypasses sys.stdout encoding.
46
+ # Strip emoji that can't be encoded on Windows cp1252.
47
+ safe_icon = ""
48
+ if icon:
49
+ try:
50
+ icon.encode("cp1252")
51
+ safe_icon = icon
52
+ except (UnicodeEncodeError, UnicodeDecodeError):
53
+ pass
54
+
55
+ label = f"{safe_icon} {title}".strip() if safe_icon else title
56
+ _console.print(f"{prefix}{connector}{label}")
57
+
58
+ # Recurse into children
59
+ children = page.get("children", [])
60
+ if not children:
61
+ return
62
+
63
+ child_prefix = prefix + (" " if is_last else "| ")
64
+ for j, child in enumerate(children):
65
+ child_is_last = j == len(children) - 1
66
+ _print_node(child, child_prefix, child_is_last)
docmost_cli/py.typed ADDED
File without changes