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.
- docmost_cli/__init__.py +5 -0
- docmost_cli/__main__.py +18 -0
- docmost_cli/api/__init__.py +5 -0
- docmost_cli/api/attachments.py +30 -0
- docmost_cli/api/auth.py +202 -0
- docmost_cli/api/client.py +296 -0
- docmost_cli/api/comments.py +103 -0
- docmost_cli/api/pages.py +530 -0
- docmost_cli/api/pagination.py +94 -0
- docmost_cli/api/search.py +40 -0
- docmost_cli/api/spaces.py +141 -0
- docmost_cli/api/users.py +25 -0
- docmost_cli/api/workspace.py +43 -0
- docmost_cli/cli/__init__.py +3 -0
- docmost_cli/cli/attachment.py +30 -0
- docmost_cli/cli/comment.py +83 -0
- docmost_cli/cli/config_cmd.py +143 -0
- docmost_cli/cli/main.py +133 -0
- docmost_cli/cli/page.py +382 -0
- docmost_cli/cli/search.py +33 -0
- docmost_cli/cli/space.py +57 -0
- docmost_cli/cli/sync_cmd.py +122 -0
- docmost_cli/cli/user.py +25 -0
- docmost_cli/cli/workspace.py +40 -0
- docmost_cli/config/__init__.py +23 -0
- docmost_cli/config/settings.py +23 -0
- docmost_cli/config/store.py +160 -0
- docmost_cli/convert/__init__.py +3 -0
- docmost_cli/convert/prosemirror_to_md.py +300 -0
- docmost_cli/models/__init__.py +3 -0
- docmost_cli/models/common.py +3 -0
- docmost_cli/output/__init__.py +17 -0
- docmost_cli/output/formatter.py +85 -0
- docmost_cli/output/tree.py +66 -0
- docmost_cli/py.typed +0 -0
- docmost_cli/sync/__init__.py +57 -0
- docmost_cli/sync/diff.py +156 -0
- docmost_cli/sync/frontmatter.py +152 -0
- docmost_cli/sync/manifest.py +195 -0
- docmost_cli/sync/pull.py +158 -0
- docmost_cli/sync/push.py +374 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-attachment.1 +57 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-comment.1 +92 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-config.1 +127 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-page.1 +412 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-search.1 +90 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-space.1 +111 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-sync.1 +206 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-user.1 +39 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli-workspace.1 +68 -0
- docmost_cli-0.4.0.data/data/share/man/man1/docmost-cli.1 +301 -0
- docmost_cli-0.4.0.dist-info/METADATA +241 -0
- docmost_cli-0.4.0.dist-info/RECORD +56 -0
- docmost_cli-0.4.0.dist-info/WHEEL +4 -0
- docmost_cli-0.4.0.dist-info/entry_points.txt +2 -0
- 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,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'\n\n'
|
|
178
|
+
return f"\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"")
|
|
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,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
|