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,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
|
+
]
|
docmost_cli/sync/diff.py
ADDED
|
@@ -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
|
+
}
|