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,57 @@
1
+ """Sync module for pulling and pushing Docmost spaces to local files."""
2
+
3
+ from docmost_cli.sync.diff import (
4
+ ChangeType,
5
+ PageChange,
6
+ SyncDiff,
7
+ compute_diff,
8
+ )
9
+ from docmost_cli.sync.frontmatter import (
10
+ parse_frontmatter,
11
+ read_sync_file,
12
+ serialize_frontmatter,
13
+ write_sync_file,
14
+ )
15
+ from docmost_cli.sync.manifest import (
16
+ MANIFEST_FILENAME,
17
+ MANIFEST_VERSION,
18
+ build_manifest,
19
+ build_page_entry,
20
+ compute_content_hash,
21
+ load_manifest,
22
+ sanitize_filename,
23
+ save_manifest,
24
+ )
25
+ from docmost_cli.sync.pull import (
26
+ PullResult,
27
+ flatten_tree,
28
+ pull_space,
29
+ )
30
+ from docmost_cli.sync.push import (
31
+ PushResult,
32
+ push_space,
33
+ )
34
+
35
+ __all__ = [
36
+ "ChangeType",
37
+ "MANIFEST_FILENAME",
38
+ "MANIFEST_VERSION",
39
+ "PageChange",
40
+ "PullResult",
41
+ "PushResult",
42
+ "SyncDiff",
43
+ "build_manifest",
44
+ "build_page_entry",
45
+ "compute_content_hash",
46
+ "compute_diff",
47
+ "flatten_tree",
48
+ "load_manifest",
49
+ "parse_frontmatter",
50
+ "pull_space",
51
+ "push_space",
52
+ "read_sync_file",
53
+ "sanitize_filename",
54
+ "save_manifest",
55
+ "serialize_frontmatter",
56
+ "write_sync_file",
57
+ ]
@@ -0,0 +1,156 @@
1
+ """Compute diff between local sync files and manifest state."""
2
+
3
+ import enum
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ __all__ = ["ChangeType", "PageChange", "SyncDiff", "compute_diff"]
9
+
10
+
11
+ class ChangeType(enum.Enum):
12
+ """Types of changes detected between local and manifest."""
13
+
14
+ NEW = "new"
15
+ CONTENT_CHANGED = "content_changed"
16
+ TITLE_CHANGED = "title_changed"
17
+ MOVED = "moved"
18
+ ICON_CHANGED = "icon_changed"
19
+ DELETED = "deleted"
20
+
21
+
22
+ @dataclass
23
+ class PageChange:
24
+ """A detected change for a single page."""
25
+
26
+ page_id: str # Empty string for NEW pages
27
+ filename: str
28
+ changes: set[ChangeType] = field(default_factory=set)
29
+ local_meta: dict[str, str] | None = None # From frontmatter
30
+ local_body: str | None = None # Markdown body
31
+ manifest_entry: dict[str, Any] | None = None # From manifest
32
+
33
+
34
+ @dataclass
35
+ class SyncDiff:
36
+ """Summary of all changes between local files and manifest."""
37
+
38
+ new: list[PageChange] = field(default_factory=list)
39
+ modified: list[PageChange] = field(default_factory=list)
40
+ moved: list[PageChange] = field(default_factory=list)
41
+ deleted: list[PageChange] = field(default_factory=list)
42
+ unchanged: int = 0
43
+
44
+ @property
45
+ def has_changes(self) -> bool:
46
+ """Return True if any changes were detected."""
47
+ return bool(self.new or self.modified or self.moved or self.deleted)
48
+
49
+
50
+ def compute_diff(manifest: dict, dir_path: Path) -> SyncDiff:
51
+ """Compute diff between local files and manifest.
52
+
53
+ Algorithm:
54
+ 1. Load manifest pages dict (keyed by page_id)
55
+ 2. Scan dir for .md files, parse frontmatter of each
56
+ 3. For each local file:
57
+ - If has id AND id in manifest: compare hash, title, parent_id, icon
58
+ - If has no id (empty string): NEW
59
+ - If has id NOT in manifest: treat as existing, classify changes
60
+ 4. For each manifest entry not matched by any local file: DELETED
61
+
62
+ A single page can appear in BOTH modified and moved if content AND parent changed.
63
+
64
+ Args:
65
+ manifest: Loaded manifest dict.
66
+ dir_path: Directory containing .md files.
67
+
68
+ Returns:
69
+ SyncDiff summarizing all changes.
70
+ """
71
+ from docmost_cli.sync.frontmatter import read_sync_file
72
+ from docmost_cli.sync.manifest import compute_content_hash
73
+
74
+ diff = SyncDiff()
75
+ manifest_pages = manifest.get("pages", {})
76
+ seen_ids: set[str] = set()
77
+
78
+ # Scan local .md files
79
+ for md_file in sorted(dir_path.glob("*.md")):
80
+ meta, body = read_sync_file(md_file)
81
+ page_id = meta.get("id", "").strip()
82
+
83
+ if not page_id:
84
+ # NEW page -- no server ID yet
85
+ change = PageChange(
86
+ page_id="",
87
+ filename=md_file.name,
88
+ changes={ChangeType.NEW},
89
+ local_meta=meta,
90
+ local_body=body,
91
+ )
92
+ diff.new.append(change)
93
+ continue
94
+
95
+ seen_ids.add(page_id)
96
+ manifest_entry = manifest_pages.get(page_id)
97
+
98
+ # Compute current content hash
99
+ current_hash = compute_content_hash(body)
100
+
101
+ # Determine changes
102
+ changes: set[ChangeType] = set()
103
+
104
+ if manifest_entry:
105
+ if current_hash != manifest_entry.get("content_hash", ""):
106
+ changes.add(ChangeType.CONTENT_CHANGED)
107
+ if meta.get("title", "") != manifest_entry.get("title", ""):
108
+ changes.add(ChangeType.TITLE_CHANGED)
109
+ if meta.get("icon", "") != manifest_entry.get("icon", ""):
110
+ changes.add(ChangeType.ICON_CHANGED)
111
+
112
+ # Compare parent_id (normalize empty string and None)
113
+ local_parent = meta.get("parent_id", "").strip() or None
114
+ manifest_parent = manifest_entry.get("parent_id") or None
115
+ if local_parent != manifest_parent:
116
+ changes.add(ChangeType.MOVED)
117
+ else:
118
+ # ID not in manifest -- treat as content-changed to trigger update
119
+ changes.add(ChangeType.CONTENT_CHANGED)
120
+
121
+ if not changes:
122
+ diff.unchanged += 1
123
+ continue
124
+
125
+ page_change = PageChange(
126
+ page_id=page_id,
127
+ filename=md_file.name,
128
+ changes=changes,
129
+ local_meta=meta,
130
+ local_body=body,
131
+ manifest_entry=manifest_entry,
132
+ )
133
+
134
+ # Categorize: if content or title or icon changed -> modified
135
+ # If parent changed -> moved (can be both modified AND moved)
136
+ if changes & {
137
+ ChangeType.CONTENT_CHANGED,
138
+ ChangeType.TITLE_CHANGED,
139
+ ChangeType.ICON_CHANGED,
140
+ }:
141
+ diff.modified.append(page_change)
142
+ if ChangeType.MOVED in changes:
143
+ diff.moved.append(page_change)
144
+
145
+ # Check for deleted pages (in manifest but no local file)
146
+ for page_id, entry in manifest_pages.items():
147
+ if page_id not in seen_ids:
148
+ change = PageChange(
149
+ page_id=page_id,
150
+ filename=entry.get("filename", ""),
151
+ changes={ChangeType.DELETED},
152
+ manifest_entry=entry,
153
+ )
154
+ diff.deleted.append(change)
155
+
156
+ return diff
@@ -0,0 +1,152 @@
1
+ """YAML frontmatter parser/serializer for sync files.
2
+
3
+ Hand-rolled for flat key-value strings only — no PyYAML dependency.
4
+ Compatible with the format produced by output/formatter.py:print_content_with_meta.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ __all__ = [
10
+ "parse_frontmatter",
11
+ "read_sync_file",
12
+ "serialize_frontmatter",
13
+ "write_sync_file",
14
+ ]
15
+
16
+ # Fixed ordering for known sync fields, ensuring consistent diffs.
17
+ _FIELD_ORDER = ("id", "title", "parent_id", "icon")
18
+
19
+
20
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
21
+ """Parse YAML frontmatter from text.
22
+
23
+ Splits on ``---`` delimiters. Parses flat ``key: value`` pairs.
24
+ Values are stripped. Keys are stripped.
25
+
26
+ Args:
27
+ text: Full file content (frontmatter + body).
28
+
29
+ Returns:
30
+ Tuple of (metadata dict, body string).
31
+ If no frontmatter found, returns (empty dict, original text).
32
+
33
+ Rules:
34
+ - First line must be ``---`` (stripped).
35
+ - Frontmatter ends at next ``---`` line.
36
+ - Each line in frontmatter is ``key: value`` (split on first ``:`` only).
37
+ - Lines without ``:`` are skipped.
38
+ - Empty values are stored as empty string.
39
+ - Body is everything after the closing ``---``, with one leading newline
40
+ stripped (if present).
41
+ """
42
+ # Normalise Windows line endings so splitting is consistent.
43
+ normalised = text.replace("\r\n", "\n")
44
+
45
+ lines = normalised.split("\n")
46
+
47
+ # First line must be '---'
48
+ if not lines or lines[0].strip() != "---":
49
+ return {}, text
50
+
51
+ # Find closing '---'
52
+ closing_idx: int | None = None
53
+ for i in range(1, len(lines)):
54
+ if lines[i].strip() == "---":
55
+ closing_idx = i
56
+ break
57
+
58
+ if closing_idx is None:
59
+ # No closing delimiter — treat entire text as body.
60
+ return {}, text
61
+
62
+ # Parse key: value pairs between the delimiters.
63
+ metadata: dict[str, str] = {}
64
+ for line in lines[1:closing_idx]:
65
+ colon_pos = line.find(":")
66
+ if colon_pos == -1:
67
+ continue
68
+ key = line[:colon_pos].strip()
69
+ value = line[colon_pos + 1 :].strip()
70
+ if key:
71
+ metadata[key] = value
72
+
73
+ # Body is everything after the closing '---'.
74
+ # Strip exactly one leading newline (the newline right after '---').
75
+ body = "\n".join(lines[closing_idx + 1 :])
76
+ if body.startswith("\n"):
77
+ body = body[1:]
78
+
79
+ return metadata, body
80
+
81
+
82
+ def serialize_frontmatter(metadata: dict[str, str], body: str) -> str:
83
+ """Combine metadata dict and body into frontmatter + markdown string.
84
+
85
+ Output format::
86
+
87
+ ---
88
+ key1: value1
89
+ key2: value2
90
+ ---
91
+
92
+ body content here
93
+
94
+ An empty line separates the closing ``---`` from the body.
95
+ Known sync fields (id, title, parent_id, icon) appear first in a fixed
96
+ order; any remaining keys follow in insertion order.
97
+
98
+ Args:
99
+ metadata: Flat key-value metadata.
100
+ body: Markdown body content.
101
+
102
+ Returns:
103
+ Combined string with YAML frontmatter header.
104
+ """
105
+ lines: list[str] = ["---"]
106
+
107
+ # Emit known fields in fixed order first.
108
+ seen: set[str] = set()
109
+ for key in _FIELD_ORDER:
110
+ if key in metadata:
111
+ lines.append(f"{key}: {metadata[key]}")
112
+ seen.add(key)
113
+
114
+ # Emit remaining fields in insertion order.
115
+ for key, value in metadata.items():
116
+ if key not in seen:
117
+ lines.append(f"{key}: {value}")
118
+
119
+ lines.append("---")
120
+ lines.append("") # blank separator line between closing --- and body
121
+ lines.append("") # ensures trailing \n after the blank line
122
+ return "\n".join(lines) + body
123
+
124
+
125
+ def read_sync_file(path: Path) -> tuple[dict[str, str], str]:
126
+ """Read a sync ``.md`` file, returning parsed frontmatter and body.
127
+
128
+ Args:
129
+ path: Path to the markdown file.
130
+
131
+ Returns:
132
+ Tuple of (metadata dict, body markdown string).
133
+
134
+ Raises:
135
+ FileNotFoundError: If *path* does not exist.
136
+ """
137
+ content = path.read_text(encoding="utf-8")
138
+ return parse_frontmatter(content)
139
+
140
+
141
+ def write_sync_file(path: Path, metadata: dict[str, str], body: str) -> None:
142
+ """Write a sync ``.md`` file with frontmatter.
143
+
144
+ Creates parent directories if needed. Writes with UTF-8 encoding.
145
+
146
+ Args:
147
+ path: Destination file path.
148
+ metadata: Flat key-value metadata for the frontmatter header.
149
+ body: Markdown body content.
150
+ """
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ path.write_text(serialize_frontmatter(metadata, body), encoding="utf-8")
@@ -0,0 +1,195 @@
1
+ """Manifest file handling for sync state tracking.
2
+
3
+ The manifest (.docmost-manifest.json) records which pages have been synced,
4
+ their content hashes, and filename mappings so that subsequent sync operations
5
+ can detect local and remote changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from datetime import UTC, datetime
14
+ from typing import TYPE_CHECKING
15
+
16
+ from docmost_cli.output.formatter import print_error
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+ __all__ = [
22
+ "MANIFEST_FILENAME",
23
+ "MANIFEST_VERSION",
24
+ "build_manifest",
25
+ "build_page_entry",
26
+ "compute_content_hash",
27
+ "load_manifest",
28
+ "sanitize_filename",
29
+ "save_manifest",
30
+ ]
31
+
32
+ MANIFEST_FILENAME = ".docmost-manifest.json"
33
+ MANIFEST_VERSION = 1
34
+
35
+ _UNSAFE_CHARS_RE = re.compile(r'[/\\:*?"<>|]')
36
+ _MULTI_DASH_RE = re.compile(r"-{2,}")
37
+ _MAX_TITLE_LENGTH = 80
38
+ _ID_PREFIX_LENGTH = 8
39
+
40
+
41
+ def sanitize_filename(title: str, page_id: str) -> str:
42
+ """Generate a safe filename from page title and ID prefix.
43
+
44
+ Format: ``{sanitized_title}--{id_prefix_8chars}.md``
45
+
46
+ Rules applied to the title portion:
47
+ - Replace ``/ \\ : * ? " < > |`` with ``-``
48
+ - Collapse multiple consecutive dashes to a single dash
49
+ - Strip leading/trailing dashes and whitespace
50
+ - Limit title portion to 80 characters
51
+ - Fall back to ``untitled`` if the title sanitizes to empty
52
+
53
+ Args:
54
+ title: The page title (may contain any characters).
55
+ page_id: The full page UUID.
56
+
57
+ Returns:
58
+ A filesystem-safe filename ending in ``.md``.
59
+ """
60
+ sanitized = _UNSAFE_CHARS_RE.sub("-", title)
61
+ sanitized = _MULTI_DASH_RE.sub("-", sanitized)
62
+ sanitized = sanitized.strip("- \t\n\r")
63
+ sanitized = sanitized[:_MAX_TITLE_LENGTH]
64
+ # Re-strip in case truncation left a trailing dash
65
+ sanitized = sanitized.rstrip("- ")
66
+ if not sanitized:
67
+ sanitized = "untitled"
68
+ id_prefix = page_id[:_ID_PREFIX_LENGTH]
69
+ return f"{sanitized}--{id_prefix}.md"
70
+
71
+
72
+ def compute_content_hash(content: str) -> str:
73
+ """Compute SHA-256 hash of content for change detection.
74
+
75
+ Strips trailing whitespace before hashing so that insignificant
76
+ trailing newlines do not cause false-positive diffs.
77
+
78
+ Args:
79
+ content: The text content to hash.
80
+
81
+ Returns:
82
+ Hash string in the format ``sha256:{hex_digest}``.
83
+ """
84
+ normalized = content.rstrip()
85
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
86
+ return f"sha256:{digest}"
87
+
88
+
89
+ def load_manifest(dir_path: Path) -> dict | None:
90
+ """Load the sync manifest from a directory.
91
+
92
+ Args:
93
+ dir_path: Directory that should contain the manifest file.
94
+
95
+ Returns:
96
+ The parsed manifest dict, or ``None`` if the file does not exist.
97
+
98
+ Raises:
99
+ SystemExit: If the manifest version is newer than what this CLI supports.
100
+ """
101
+ manifest_path = dir_path / MANIFEST_FILENAME
102
+ try:
103
+ with manifest_path.open("r", encoding="utf-8") as f:
104
+ manifest: dict = json.load(f)
105
+ except FileNotFoundError:
106
+ return None
107
+ version = manifest.get("version", 0)
108
+ if version > MANIFEST_VERSION:
109
+ print_error(
110
+ f"Manifest version {version} is not supported by this CLI "
111
+ f"(max supported: {MANIFEST_VERSION}). Please upgrade docmost-cli."
112
+ )
113
+ return manifest
114
+
115
+
116
+ def save_manifest(dir_path: Path, manifest: dict) -> None:
117
+ """Save the sync manifest to a directory as pretty-printed JSON.
118
+
119
+ Uses atomic write (write to ``.tmp`` then rename) to avoid
120
+ partial writes on crash.
121
+
122
+ Args:
123
+ dir_path: Directory where the manifest file will be written.
124
+ manifest: The manifest dict to persist.
125
+ """
126
+ manifest_path = dir_path / MANIFEST_FILENAME
127
+ tmp_path = dir_path / (MANIFEST_FILENAME + ".tmp")
128
+ with tmp_path.open("w", encoding="utf-8") as f:
129
+ json.dump(manifest, f, indent=2, ensure_ascii=False)
130
+ f.write("\n")
131
+ tmp_path.replace(manifest_path)
132
+
133
+
134
+ def build_manifest(
135
+ space_slug: str,
136
+ space_id: str,
137
+ pages: list[dict],
138
+ ) -> dict:
139
+ """Build a new manifest dict from page data.
140
+
141
+ Args:
142
+ space_slug: The space slug identifier (e.g. ``"eng"``).
143
+ space_id: The space UUID.
144
+ pages: List of dicts, each with keys:
145
+ ``id``, ``title``, ``filename``, ``parent_id``, ``icon``,
146
+ ``content_hash``.
147
+
148
+ Returns:
149
+ A complete manifest dict ready for :func:`save_manifest`.
150
+ """
151
+ now = datetime.now(UTC).isoformat()
152
+ pages_by_id: dict[str, dict] = {}
153
+ for page in pages:
154
+ pages_by_id[page["id"]] = build_page_entry(
155
+ title=page["title"],
156
+ filename=page["filename"],
157
+ parent_id=page.get("parent_id"),
158
+ icon=page.get("icon", ""),
159
+ content_hash=page["content_hash"],
160
+ )
161
+ return {
162
+ "version": MANIFEST_VERSION,
163
+ "space_slug": space_slug,
164
+ "space_id": space_id,
165
+ "synced_at": now,
166
+ "pages": pages_by_id,
167
+ }
168
+
169
+
170
+ def build_page_entry(
171
+ title: str,
172
+ filename: str,
173
+ parent_id: str | None,
174
+ icon: str,
175
+ content_hash: str,
176
+ ) -> dict:
177
+ """Build a single page entry for inclusion in the manifest.
178
+
179
+ Args:
180
+ title: Page title.
181
+ filename: The sanitized local filename.
182
+ parent_id: Parent page ID, or ``None`` for root pages.
183
+ icon: Page icon (emoji or empty string).
184
+ content_hash: Content hash from :func:`compute_content_hash`.
185
+
186
+ Returns:
187
+ A dict representing one page in the manifest ``pages`` map.
188
+ """
189
+ return {
190
+ "title": title,
191
+ "filename": filename,
192
+ "parent_id": parent_id,
193
+ "icon": icon,
194
+ "content_hash": content_hash,
195
+ }