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
docmost_cli/sync/pull.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Pull space pages from Docmost server to local directory."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from docmost_cli.api.client import DocmostClient
|
|
8
|
+
from docmost_cli.output.formatter import _err_console as _err
|
|
9
|
+
|
|
10
|
+
__all__ = ["PullResult", "flatten_tree", "pull_space"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PullResult:
|
|
15
|
+
"""Result of a pull operation."""
|
|
16
|
+
|
|
17
|
+
pages_pulled: int
|
|
18
|
+
dir_path: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def flatten_tree(
|
|
22
|
+
pages: list[dict[str, Any]],
|
|
23
|
+
parent_id: str | None = None,
|
|
24
|
+
) -> list[dict[str, Any]]:
|
|
25
|
+
"""Flatten nested page tree into a flat list with parent_id.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
pages: Nested page tree from build_page_tree.
|
|
29
|
+
parent_id: Parent page ID for current level.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Flat list of dicts with: id, title, icon, parent_id
|
|
33
|
+
"""
|
|
34
|
+
result: list[dict[str, Any]] = []
|
|
35
|
+
for page in pages:
|
|
36
|
+
result.append(
|
|
37
|
+
{
|
|
38
|
+
"id": page["id"],
|
|
39
|
+
"title": page.get("title", ""),
|
|
40
|
+
"icon": page.get("icon") or "",
|
|
41
|
+
"parent_id": parent_id,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
children = page.get("children", [])
|
|
45
|
+
if children:
|
|
46
|
+
result.extend(flatten_tree(children, parent_id=page["id"]))
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pull_space(
|
|
51
|
+
client: DocmostClient,
|
|
52
|
+
space_slug: str,
|
|
53
|
+
dir_path: Path,
|
|
54
|
+
*,
|
|
55
|
+
force: bool = False,
|
|
56
|
+
) -> PullResult:
|
|
57
|
+
"""Pull all pages from a space to a local directory.
|
|
58
|
+
|
|
59
|
+
Algorithm:
|
|
60
|
+
1. Resolve space slug to ID
|
|
61
|
+
2. Build full page tree
|
|
62
|
+
3. Flatten tree to list with parent_id
|
|
63
|
+
4. For each page: fetch content, convert to markdown, write file
|
|
64
|
+
5. Write manifest LAST (atomic commit point)
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
client: Authenticated Docmost client.
|
|
68
|
+
space_slug: Space slug identifier.
|
|
69
|
+
dir_path: Target directory path.
|
|
70
|
+
force: Overwrite existing files without warning.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
PullResult with count and path.
|
|
74
|
+
"""
|
|
75
|
+
from docmost_cli.api.pages import build_page_tree, get_page_content
|
|
76
|
+
from docmost_cli.api.spaces import resolve_space_id
|
|
77
|
+
from docmost_cli.convert.prosemirror_to_md import convert_to_markdown
|
|
78
|
+
from docmost_cli.output.formatter import print_error
|
|
79
|
+
from docmost_cli.sync.frontmatter import write_sync_file
|
|
80
|
+
from docmost_cli.sync.manifest import (
|
|
81
|
+
build_manifest,
|
|
82
|
+
build_page_entry,
|
|
83
|
+
compute_content_hash,
|
|
84
|
+
load_manifest,
|
|
85
|
+
sanitize_filename,
|
|
86
|
+
save_manifest,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 1. Resolve space
|
|
90
|
+
space_id = resolve_space_id(client, space_slug)
|
|
91
|
+
|
|
92
|
+
# 2. Build page tree
|
|
93
|
+
_err.print(f"Fetching page tree for '{space_slug}'...")
|
|
94
|
+
tree = build_page_tree(client, space_id)
|
|
95
|
+
|
|
96
|
+
# 3. Flatten
|
|
97
|
+
flat_pages = flatten_tree(tree)
|
|
98
|
+
total = len(flat_pages)
|
|
99
|
+
|
|
100
|
+
if total == 0:
|
|
101
|
+
# Empty space -- create dir + empty manifest
|
|
102
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
manifest = build_manifest(space_slug, space_id, [])
|
|
104
|
+
save_manifest(dir_path, manifest)
|
|
105
|
+
_err.print(f"Pulled 0 pages from '{space_slug}' -> {dir_path}")
|
|
106
|
+
return PullResult(pages_pulled=0, dir_path=dir_path)
|
|
107
|
+
|
|
108
|
+
# 4. Check target directory
|
|
109
|
+
if dir_path.exists():
|
|
110
|
+
existing_manifest = load_manifest(dir_path)
|
|
111
|
+
if existing_manifest and not force:
|
|
112
|
+
print_error(
|
|
113
|
+
f"Directory '{dir_path}' already has synced data. Use --force to overwrite."
|
|
114
|
+
)
|
|
115
|
+
# If dir exists with no manifest OR force is set, proceed
|
|
116
|
+
|
|
117
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
# 5. Fetch content and write files
|
|
120
|
+
page_entries: list[dict[str, Any]] = []
|
|
121
|
+
for i, page_info in enumerate(flat_pages, 1):
|
|
122
|
+
page_id = page_info["id"]
|
|
123
|
+
title = page_info["title"]
|
|
124
|
+
_err.print(f"Pulling {i}/{total}: {title}")
|
|
125
|
+
|
|
126
|
+
# Fetch content
|
|
127
|
+
content_data = get_page_content(client, page_id)
|
|
128
|
+
pm_content = content_data.get("content")
|
|
129
|
+
|
|
130
|
+
markdown = convert_to_markdown(pm_content) if pm_content else ""
|
|
131
|
+
|
|
132
|
+
# Generate filename and write file
|
|
133
|
+
filename = sanitize_filename(title, page_id)
|
|
134
|
+
metadata = {
|
|
135
|
+
"id": page_id,
|
|
136
|
+
"title": title,
|
|
137
|
+
"parent_id": page_info["parent_id"] or "",
|
|
138
|
+
"icon": page_info["icon"],
|
|
139
|
+
}
|
|
140
|
+
write_sync_file(dir_path / filename, metadata, markdown)
|
|
141
|
+
|
|
142
|
+
# Build manifest entry
|
|
143
|
+
content_hash = compute_content_hash(markdown)
|
|
144
|
+
entry = build_page_entry(
|
|
145
|
+
title=title,
|
|
146
|
+
filename=filename,
|
|
147
|
+
parent_id=page_info["parent_id"],
|
|
148
|
+
icon=page_info["icon"],
|
|
149
|
+
content_hash=content_hash,
|
|
150
|
+
)
|
|
151
|
+
page_entries.append({"id": page_id, **entry})
|
|
152
|
+
|
|
153
|
+
# 6. Write manifest LAST
|
|
154
|
+
manifest = build_manifest(space_slug, space_id, page_entries)
|
|
155
|
+
save_manifest(dir_path, manifest)
|
|
156
|
+
|
|
157
|
+
_err.print(f"Pulled {total} pages from '{space_slug}' -> {dir_path}")
|
|
158
|
+
return PullResult(pages_pulled=total, dir_path=dir_path)
|
docmost_cli/sync/push.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Push local changes to Docmost server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from docmost_cli.output.formatter import _err_console as _err
|
|
9
|
+
from docmost_cli.sync.diff import ChangeType, SyncDiff
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from docmost_cli.api.client import DocmostClient
|
|
15
|
+
|
|
16
|
+
__all__ = ["PushResult", "push_space"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PushResult:
|
|
21
|
+
"""Result of a push operation."""
|
|
22
|
+
|
|
23
|
+
created: int = 0
|
|
24
|
+
updated: int = 0
|
|
25
|
+
moved: int = 0
|
|
26
|
+
deleted: int = 0
|
|
27
|
+
unchanged: int = 0
|
|
28
|
+
id_remaps: dict[str, str] = field(default_factory=dict) # old_id -> new_id
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def push_space(
|
|
32
|
+
client: DocmostClient,
|
|
33
|
+
space_slug: str,
|
|
34
|
+
dir_path: Path,
|
|
35
|
+
*,
|
|
36
|
+
dry_run: bool = False,
|
|
37
|
+
delete: bool = False,
|
|
38
|
+
diff: SyncDiff | None = None,
|
|
39
|
+
) -> PushResult:
|
|
40
|
+
"""Push local changes to Docmost server.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
client: Authenticated Docmost client.
|
|
44
|
+
space_slug: Space slug identifier.
|
|
45
|
+
dir_path: Directory containing synced files.
|
|
46
|
+
dry_run: If True, show plan without executing changes.
|
|
47
|
+
delete: If True, delete server pages not found locally.
|
|
48
|
+
diff: Pre-computed diff (avoids recomputing if caller already has it).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
PushResult with counts and any ID remaps.
|
|
52
|
+
"""
|
|
53
|
+
from docmost_cli.api.pages import (
|
|
54
|
+
POSITION_FIRST,
|
|
55
|
+
create_and_place_page,
|
|
56
|
+
delete_page,
|
|
57
|
+
move_page,
|
|
58
|
+
try_update_page_content,
|
|
59
|
+
update_page_meta,
|
|
60
|
+
)
|
|
61
|
+
from docmost_cli.api.spaces import resolve_space_id
|
|
62
|
+
from docmost_cli.output.formatter import print_error
|
|
63
|
+
from docmost_cli.sync.diff import compute_diff
|
|
64
|
+
from docmost_cli.sync.frontmatter import write_sync_file
|
|
65
|
+
from docmost_cli.sync.manifest import (
|
|
66
|
+
build_page_entry,
|
|
67
|
+
compute_content_hash,
|
|
68
|
+
load_manifest,
|
|
69
|
+
save_manifest,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
space_id = resolve_space_id(client, space_slug)
|
|
73
|
+
|
|
74
|
+
manifest = load_manifest(dir_path)
|
|
75
|
+
if manifest is None:
|
|
76
|
+
print_error(f"No manifest found in '{dir_path}'. Run 'sync pull' first.")
|
|
77
|
+
|
|
78
|
+
if diff is None:
|
|
79
|
+
diff = compute_diff(manifest, dir_path)
|
|
80
|
+
result = PushResult(unchanged=diff.unchanged)
|
|
81
|
+
|
|
82
|
+
if not diff.has_changes:
|
|
83
|
+
_err.print("No changes to push.")
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
# Display summary
|
|
87
|
+
_print_summary(diff)
|
|
88
|
+
|
|
89
|
+
if dry_run:
|
|
90
|
+
_print_dry_run(diff)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# --- Execute changes ---
|
|
94
|
+
|
|
95
|
+
enterprise: bool | None = None # Cached edition detection
|
|
96
|
+
id_remap: dict[str, str] = {} # old_id -> new_id
|
|
97
|
+
|
|
98
|
+
# Phase A: Create new pages (topological order)
|
|
99
|
+
existing_ids = set(manifest.get("pages", {}).keys())
|
|
100
|
+
sorted_new = _topological_sort(diff.new, existing_ids)
|
|
101
|
+
|
|
102
|
+
for change in sorted_new:
|
|
103
|
+
meta = change.local_meta or {}
|
|
104
|
+
body = change.local_body or ""
|
|
105
|
+
title = meta.get("title", "Untitled")
|
|
106
|
+
parent_id = meta.get("parent_id", "").strip() or None
|
|
107
|
+
icon = meta.get("icon", "").strip()
|
|
108
|
+
|
|
109
|
+
# Resolve parent_id through remap table
|
|
110
|
+
if parent_id and parent_id in id_remap:
|
|
111
|
+
parent_id = id_remap[parent_id]
|
|
112
|
+
|
|
113
|
+
_err.print(f" Creating: {title}")
|
|
114
|
+
new_id = create_and_place_page(
|
|
115
|
+
client,
|
|
116
|
+
space_id=space_id,
|
|
117
|
+
title=title,
|
|
118
|
+
content=body,
|
|
119
|
+
parent_page_id=parent_id,
|
|
120
|
+
icon=icon,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Write ID back to frontmatter
|
|
124
|
+
meta["id"] = new_id
|
|
125
|
+
write_sync_file(dir_path / change.filename, meta, body)
|
|
126
|
+
|
|
127
|
+
# Update manifest
|
|
128
|
+
content_hash = compute_content_hash(body)
|
|
129
|
+
manifest["pages"][new_id] = build_page_entry(
|
|
130
|
+
title=title,
|
|
131
|
+
filename=change.filename,
|
|
132
|
+
parent_id=parent_id,
|
|
133
|
+
icon=icon,
|
|
134
|
+
content_hash=content_hash,
|
|
135
|
+
)
|
|
136
|
+
existing_ids.add(new_id)
|
|
137
|
+
result.created += 1
|
|
138
|
+
|
|
139
|
+
# Phase B: Update modified pages
|
|
140
|
+
for change in diff.modified:
|
|
141
|
+
meta = change.local_meta or {}
|
|
142
|
+
body = change.local_body or ""
|
|
143
|
+
page_id = change.page_id
|
|
144
|
+
title = meta.get("title", "")
|
|
145
|
+
parent_id = meta.get("parent_id", "").strip() or None
|
|
146
|
+
icon = meta.get("icon", "").strip()
|
|
147
|
+
|
|
148
|
+
has_content_change = ChangeType.CONTENT_CHANGED in change.changes
|
|
149
|
+
has_meta_change = bool(change.changes & {ChangeType.TITLE_CHANGED, ChangeType.ICON_CHANGED})
|
|
150
|
+
|
|
151
|
+
# Content update
|
|
152
|
+
if has_content_change:
|
|
153
|
+
if enterprise is None:
|
|
154
|
+
# First attempt: probe and update in one call
|
|
155
|
+
enterprise = try_update_page_content(client, page_id=page_id, content=body)
|
|
156
|
+
if enterprise:
|
|
157
|
+
_err.print(f" Updated (Enterprise): {title}")
|
|
158
|
+
elif enterprise:
|
|
159
|
+
try_update_page_content(client, page_id=page_id, content=body)
|
|
160
|
+
_err.print(f" Updated: {title}")
|
|
161
|
+
|
|
162
|
+
if not enterprise:
|
|
163
|
+
# Community: safe create-then-delete
|
|
164
|
+
_err.print(f" Replacing: {title}")
|
|
165
|
+
new_id = _community_replace(
|
|
166
|
+
client,
|
|
167
|
+
space_id=space_id,
|
|
168
|
+
old_page_id=page_id,
|
|
169
|
+
title=title,
|
|
170
|
+
content=body,
|
|
171
|
+
parent_id=parent_id,
|
|
172
|
+
icon=icon,
|
|
173
|
+
)
|
|
174
|
+
id_remap[page_id] = new_id
|
|
175
|
+
meta["id"] = new_id
|
|
176
|
+
write_sync_file(dir_path / change.filename, meta, body)
|
|
177
|
+
manifest["pages"].pop(page_id, None)
|
|
178
|
+
page_id = new_id
|
|
179
|
+
|
|
180
|
+
# Meta update (title/icon) — skip if community update already recreated the page
|
|
181
|
+
if has_meta_change and not (has_content_change and not enterprise):
|
|
182
|
+
_err.print(f" Metadata: {title}")
|
|
183
|
+
update_page_meta(
|
|
184
|
+
client,
|
|
185
|
+
page_id=page_id,
|
|
186
|
+
title=title if ChangeType.TITLE_CHANGED in change.changes else None,
|
|
187
|
+
icon=icon if ChangeType.ICON_CHANGED in change.changes else None,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Update manifest entry
|
|
191
|
+
content_hash = compute_content_hash(body)
|
|
192
|
+
manifest["pages"][page_id] = build_page_entry(
|
|
193
|
+
title=title,
|
|
194
|
+
filename=change.filename,
|
|
195
|
+
parent_id=parent_id,
|
|
196
|
+
icon=icon,
|
|
197
|
+
content_hash=content_hash,
|
|
198
|
+
)
|
|
199
|
+
result.updated += 1
|
|
200
|
+
|
|
201
|
+
# Phase B2: Move pages (that weren't already handled as part of modified)
|
|
202
|
+
modified_ids = {c.page_id for c in diff.modified}
|
|
203
|
+
for change in diff.moved:
|
|
204
|
+
if change.page_id in modified_ids:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
meta = change.local_meta or {}
|
|
208
|
+
page_id = change.page_id
|
|
209
|
+
parent_id = meta.get("parent_id", "").strip() or None
|
|
210
|
+
title = meta.get("title", page_id)
|
|
211
|
+
|
|
212
|
+
# Check remap
|
|
213
|
+
if page_id in id_remap:
|
|
214
|
+
page_id = id_remap[page_id]
|
|
215
|
+
if parent_id and parent_id in id_remap:
|
|
216
|
+
parent_id = id_remap[parent_id]
|
|
217
|
+
|
|
218
|
+
_err.print(f" Moving: {title}")
|
|
219
|
+
move_page(
|
|
220
|
+
client,
|
|
221
|
+
page_id=page_id,
|
|
222
|
+
parent_page_id=parent_id,
|
|
223
|
+
position=POSITION_FIRST,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Update manifest
|
|
227
|
+
if page_id in manifest["pages"]:
|
|
228
|
+
manifest["pages"][page_id]["parent_id"] = parent_id
|
|
229
|
+
result.moved += 1
|
|
230
|
+
|
|
231
|
+
# Phase C: Deletions
|
|
232
|
+
if diff.deleted:
|
|
233
|
+
if delete:
|
|
234
|
+
for change in diff.deleted:
|
|
235
|
+
entry = change.manifest_entry or {}
|
|
236
|
+
_err.print(f" Deleting: {entry.get('title', change.page_id)}")
|
|
237
|
+
delete_page(client, change.page_id)
|
|
238
|
+
manifest["pages"].pop(change.page_id, None)
|
|
239
|
+
result.deleted += 1
|
|
240
|
+
else:
|
|
241
|
+
_err.print(
|
|
242
|
+
f" [yellow]{len(diff.deleted)} page(s) on server not found locally. "
|
|
243
|
+
"Use --delete to remove.[/yellow]"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Save ID remaps
|
|
247
|
+
result.id_remaps = id_remap
|
|
248
|
+
if id_remap:
|
|
249
|
+
_err.print(
|
|
250
|
+
f"[yellow]Community edition: {len(id_remap)} page(s) got new IDs. "
|
|
251
|
+
"Internal wiki links may need updating.[/yellow]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Save manifest
|
|
255
|
+
save_manifest(dir_path, manifest)
|
|
256
|
+
|
|
257
|
+
_err.print(
|
|
258
|
+
f"Pushed to '{space_slug}': "
|
|
259
|
+
f"{result.created} created, {result.updated} updated, "
|
|
260
|
+
f"{result.moved} moved, {result.deleted} deleted"
|
|
261
|
+
)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _community_replace(
|
|
266
|
+
client: DocmostClient,
|
|
267
|
+
*,
|
|
268
|
+
space_id: str,
|
|
269
|
+
old_page_id: str,
|
|
270
|
+
title: str,
|
|
271
|
+
content: str,
|
|
272
|
+
parent_id: str | None,
|
|
273
|
+
icon: str,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Safe content update for Community edition: create new, then delete old.
|
|
276
|
+
|
|
277
|
+
The old page is only deleted after the new one is confirmed created.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
New page ID.
|
|
281
|
+
"""
|
|
282
|
+
from docmost_cli.api.pages import create_and_place_page, delete_page
|
|
283
|
+
|
|
284
|
+
new_id = create_and_place_page(
|
|
285
|
+
client,
|
|
286
|
+
space_id=space_id,
|
|
287
|
+
title=title,
|
|
288
|
+
content=content,
|
|
289
|
+
parent_page_id=parent_id,
|
|
290
|
+
icon=icon,
|
|
291
|
+
)
|
|
292
|
+
delete_page(client, old_page_id)
|
|
293
|
+
return new_id
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _topological_sort(new_changes: list, existing_ids: set[str]) -> list:
|
|
297
|
+
"""Sort new pages so parents are created before children.
|
|
298
|
+
|
|
299
|
+
Pages with no parent or whose parent already exists on the server
|
|
300
|
+
are placed first. Pages whose parent_id references a server ID not
|
|
301
|
+
yet in the resolved set are deferred. Note: new pages have empty
|
|
302
|
+
page_id, so cross-references between new pages are not supported —
|
|
303
|
+
only references to existing server IDs are resolved.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
new_changes: List of PageChange with NEW type.
|
|
307
|
+
existing_ids: Set of page IDs already on the server.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Sorted list of PageChange.
|
|
311
|
+
"""
|
|
312
|
+
result = []
|
|
313
|
+
remaining = list(new_changes)
|
|
314
|
+
resolved = set(existing_ids)
|
|
315
|
+
|
|
316
|
+
max_iterations = len(remaining) + 1
|
|
317
|
+
for _ in range(max_iterations):
|
|
318
|
+
if not remaining:
|
|
319
|
+
break
|
|
320
|
+
next_remaining = []
|
|
321
|
+
for change in remaining:
|
|
322
|
+
meta = change.local_meta or {}
|
|
323
|
+
parent_id = meta.get("parent_id", "").strip() or None
|
|
324
|
+
if parent_id is None or parent_id in resolved:
|
|
325
|
+
result.append(change)
|
|
326
|
+
else:
|
|
327
|
+
next_remaining.append(change)
|
|
328
|
+
if len(next_remaining) == len(remaining):
|
|
329
|
+
# No progress — circular or broken parent reference — add remaining
|
|
330
|
+
result.extend(next_remaining)
|
|
331
|
+
break
|
|
332
|
+
remaining = next_remaining
|
|
333
|
+
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _print_summary(diff: SyncDiff) -> None:
|
|
338
|
+
"""Print change summary to stderr."""
|
|
339
|
+
lines: list[str] = []
|
|
340
|
+
if diff.new:
|
|
341
|
+
lines.append(f" Create: {len(diff.new)} page(s)")
|
|
342
|
+
if diff.modified:
|
|
343
|
+
lines.append(f" Update: {len(diff.modified)} page(s)")
|
|
344
|
+
if diff.moved:
|
|
345
|
+
move_only = [c for c in diff.moved if c not in diff.modified]
|
|
346
|
+
if move_only:
|
|
347
|
+
lines.append(f" Move: {len(move_only)} page(s)")
|
|
348
|
+
if diff.deleted:
|
|
349
|
+
lines.append(f" Delete: {len(diff.deleted)} page(s)")
|
|
350
|
+
lines.append(f" Unchanged: {diff.unchanged} page(s)")
|
|
351
|
+
_err.print("Push plan:")
|
|
352
|
+
for line in lines:
|
|
353
|
+
_err.print(line)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _print_dry_run(diff: SyncDiff) -> None:
|
|
357
|
+
"""Print detailed plan to stdout for scripting."""
|
|
358
|
+
import sys
|
|
359
|
+
|
|
360
|
+
for change in diff.new:
|
|
361
|
+
meta = change.local_meta or {}
|
|
362
|
+
sys.stdout.write(f"CREATE {change.filename} ({meta.get('title', '?')})\n")
|
|
363
|
+
for change in diff.modified:
|
|
364
|
+
types = ", ".join(c.value for c in change.changes if c != ChangeType.MOVED)
|
|
365
|
+
sys.stdout.write(f"UPDATE {change.filename} ({types})\n")
|
|
366
|
+
for change in diff.moved:
|
|
367
|
+
if change not in diff.modified:
|
|
368
|
+
meta = change.local_meta or {}
|
|
369
|
+
sys.stdout.write(
|
|
370
|
+
f"MOVE {change.filename} -> parent:{meta.get('parent_id', 'root')}\n"
|
|
371
|
+
)
|
|
372
|
+
for change in diff.deleted:
|
|
373
|
+
entry = change.manifest_entry or {}
|
|
374
|
+
sys.stdout.write(f"DELETE {entry.get('filename', '?')} ({entry.get('title', '?')})\n")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.\" Manual page for docmost-cli-attachment(1)
|
|
2
|
+
.\" Source: SPECIFICATION.md section 4.6, src/docmost_cli/cli/attachment.py
|
|
3
|
+
.TH DOCMOST\-CLI\-ATTACHMENT 1 "2026\-03\-22" "docmost\-cli 0.4.0" "User Commands"
|
|
4
|
+
.SH NAME
|
|
5
|
+
docmost\-cli\-attachment \- attachment operations for Docmost wiki
|
|
6
|
+
.SH SYNOPSIS
|
|
7
|
+
.B docmost\-cli attachment search
|
|
8
|
+
.I query
|
|
9
|
+
.RB [ \-\-space
|
|
10
|
+
.IR slug ]
|
|
11
|
+
.RB [ \-\-json ]
|
|
12
|
+
.SH DESCRIPTION
|
|
13
|
+
Commands for working with file attachments in a Docmost wiki.
|
|
14
|
+
.SH SUBCOMMANDS
|
|
15
|
+
.SS search
|
|
16
|
+
Search for attachments by name or content.
|
|
17
|
+
.PP
|
|
18
|
+
.I query
|
|
19
|
+
is the search string.
|
|
20
|
+
.TP
|
|
21
|
+
.BI \-\-space " slug"
|
|
22
|
+
Restrict search to attachments in a specific space.
|
|
23
|
+
.TP
|
|
24
|
+
.B \-\-json
|
|
25
|
+
Output as a JSON array instead of a Rich table.
|
|
26
|
+
.PP
|
|
27
|
+
Table columns: id, fileName, type.
|
|
28
|
+
.SH EXAMPLES
|
|
29
|
+
Search for attachments:
|
|
30
|
+
.PP
|
|
31
|
+
.RS 4
|
|
32
|
+
.EX
|
|
33
|
+
$ docmost\-cli attachment search "architecture diagram"
|
|
34
|
+
.EE
|
|
35
|
+
.RE
|
|
36
|
+
.PP
|
|
37
|
+
Search within a space:
|
|
38
|
+
.PP
|
|
39
|
+
.RS 4
|
|
40
|
+
.EX
|
|
41
|
+
$ docmost\-cli attachment search "logo" \-\-space design
|
|
42
|
+
.EE
|
|
43
|
+
.RE
|
|
44
|
+
.SH EXIT STATUS
|
|
45
|
+
See
|
|
46
|
+
.BR docmost\-cli (1).
|
|
47
|
+
.SH SEE ALSO
|
|
48
|
+
.BR docmost\-cli (1),
|
|
49
|
+
.BR docmost\-cli\-search (1),
|
|
50
|
+
.BR docmost\-cli\-page (1)
|
|
51
|
+
.SH AUTHORS
|
|
52
|
+
Georg
|
|
53
|
+
.MT georg@mann\-mouse.at
|
|
54
|
+
.ME .
|
|
55
|
+
.SH COPYRIGHT
|
|
56
|
+
Copyright \(co 2026 Georg.
|
|
57
|
+
License AGPLv3+: GNU Affero General Public License version 3 or later.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
.\" Manual page for docmost-cli-comment(1)
|
|
2
|
+
.\" Source: SPECIFICATION.md section 4.4, src/docmost_cli/cli/comment.py
|
|
3
|
+
.TH DOCMOST\-CLI\-COMMENT 1 "2026\-03\-22" "docmost\-cli 0.4.0" "User Commands"
|
|
4
|
+
.SH NAME
|
|
5
|
+
docmost\-cli\-comment \- comment operations for Docmost wiki pages
|
|
6
|
+
.SH SYNOPSIS
|
|
7
|
+
.B docmost\-cli comment list
|
|
8
|
+
.I page\-id
|
|
9
|
+
.RB [ \-\-json ]
|
|
10
|
+
.br
|
|
11
|
+
.B docmost\-cli comment create
|
|
12
|
+
.I page\-id
|
|
13
|
+
.BI \-\-content " text"
|
|
14
|
+
.br
|
|
15
|
+
.B docmost\-cli comment update
|
|
16
|
+
.I comment\-id
|
|
17
|
+
.BI \-\-content " text"
|
|
18
|
+
.SH DESCRIPTION
|
|
19
|
+
Commands for managing comments on Docmost wiki pages.
|
|
20
|
+
Comments are displayed as plain text extracted from the underlying
|
|
21
|
+
ProseMirror content, truncated to approximately 100 characters in
|
|
22
|
+
table output.
|
|
23
|
+
.SH SUBCOMMANDS
|
|
24
|
+
.SS list
|
|
25
|
+
List comments on a page.
|
|
26
|
+
.PP
|
|
27
|
+
.I page\-id
|
|
28
|
+
is the UUID of the page whose comments to list.
|
|
29
|
+
.TP
|
|
30
|
+
.B \-\-json
|
|
31
|
+
Output as a JSON array instead of a Rich table.
|
|
32
|
+
.PP
|
|
33
|
+
Table columns: id, content, creatorId, createdAt.
|
|
34
|
+
.SS create
|
|
35
|
+
Add a comment to a page.
|
|
36
|
+
.PP
|
|
37
|
+
.I page\-id
|
|
38
|
+
is the UUID of the page to comment on.
|
|
39
|
+
.TP
|
|
40
|
+
.BI \-\-content " text"
|
|
41
|
+
Comment text.
|
|
42
|
+
Required.
|
|
43
|
+
.PP
|
|
44
|
+
Outputs the new comment ID to stdout.
|
|
45
|
+
.SS update
|
|
46
|
+
Update an existing comment.
|
|
47
|
+
.PP
|
|
48
|
+
.I comment\-id
|
|
49
|
+
is the UUID of the comment to update.
|
|
50
|
+
.TP
|
|
51
|
+
.BI \-\-content " text"
|
|
52
|
+
New comment text.
|
|
53
|
+
Required.
|
|
54
|
+
.PP
|
|
55
|
+
Outputs the updated comment ID to stdout.
|
|
56
|
+
.SH EXAMPLES
|
|
57
|
+
List comments on a page:
|
|
58
|
+
.PP
|
|
59
|
+
.RS 4
|
|
60
|
+
.EX
|
|
61
|
+
$ docmost\-cli comment list 019a2a69\-xxxx
|
|
62
|
+
.EE
|
|
63
|
+
.RE
|
|
64
|
+
.PP
|
|
65
|
+
Add a comment:
|
|
66
|
+
.PP
|
|
67
|
+
.RS 4
|
|
68
|
+
.EX
|
|
69
|
+
$ docmost\-cli comment create 019a2a69\-xxxx \-\-content "Looks good, ship it!"
|
|
70
|
+
.EE
|
|
71
|
+
.RE
|
|
72
|
+
.PP
|
|
73
|
+
Update a comment:
|
|
74
|
+
.PP
|
|
75
|
+
.RS 4
|
|
76
|
+
.EX
|
|
77
|
+
$ docmost\-cli comment update 019b3c8f\-yyyy \-\-content "Updated review notes"
|
|
78
|
+
.EE
|
|
79
|
+
.RE
|
|
80
|
+
.SH EXIT STATUS
|
|
81
|
+
See
|
|
82
|
+
.BR docmost\-cli (1).
|
|
83
|
+
.SH SEE ALSO
|
|
84
|
+
.BR docmost\-cli (1),
|
|
85
|
+
.BR docmost\-cli\-page (1)
|
|
86
|
+
.SH AUTHORS
|
|
87
|
+
Georg
|
|
88
|
+
.MT georg@mann\-mouse.at
|
|
89
|
+
.ME .
|
|
90
|
+
.SH COPYRIGHT
|
|
91
|
+
Copyright \(co 2026 Georg.
|
|
92
|
+
License AGPLv3+: GNU Affero General Public License version 3 or later.
|