obris-cli 0.4.2__tar.gz → 0.5.0__tar.gz
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.
- {obris_cli-0.4.2 → obris_cli-0.5.0}/.gitignore +3 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/PKG-INFO +1 -1
- {obris_cli-0.4.2 → obris_cli-0.5.0}/pyproject.toml +1 -1
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/topics.py +25 -7
- obris_cli-0.5.0/src/obris/commands/sync.py +273 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/routes.py +4 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/commands.py +15 -10
- obris_cli-0.5.0/src/obris/sync/engine/__init__.py +13 -0
- obris_cli-0.5.0/src/obris/sync/engine/filters.py +62 -0
- obris_cli-0.5.0/src/obris/sync/engine/run.py +208 -0
- obris_cli-0.5.0/src/obris/sync/engine/subtree.py +165 -0
- obris_cli-0.5.0/src/obris/sync/engine/tracked.py +198 -0
- obris_cli-0.5.0/src/obris/sync/mapping.py +239 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/models.py +52 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/state.py +48 -9
- obris_cli-0.4.2/src/obris/commands/sync.py +0 -180
- obris_cli-0.4.2/src/obris/sync/engine.py +0 -253
- obris_cli-0.4.2/src/obris/sync/mapping.py +0 -122
- {obris_cli-0.4.2 → obris_cli-0.5.0}/.claude/settings.local.json +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/README.md +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/ruff.toml +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/client.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/api/knowledge.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/auth/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/auth/session.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/cli.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/auth.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/env.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/save.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/config.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/output.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/constants.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/sync/io.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/notify.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/src/obris/utils/upload.py +0 -0
- {obris_cli-0.4.2 → obris_cli-0.5.0}/uv.lock +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from obris import routes
|
|
2
2
|
from obris.api.client import get, post
|
|
3
|
+
from obris.sync.models import RemoteTopic
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
def list_topics(*, name=None, is_system=None):
|
|
@@ -11,12 +12,21 @@ def list_topics(*, name=None, is_system=None):
|
|
|
11
12
|
return get(routes.topics(), params=params, action="List topics", unwrap=True)
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def get_topic(topic_id):
|
|
15
|
-
|
|
15
|
+
def get_topic(topic_id) -> RemoteTopic:
|
|
16
|
+
"""Fetch a single topic. Returns a ``RemoteTopic`` DTO so callers
|
|
17
|
+
do attribute access instead of dict .get() calls — same pattern as
|
|
18
|
+
``RemoteItem`` on the knowledge side."""
|
|
19
|
+
return RemoteTopic.from_api(get(routes.topic(topic_id), action="Get topic"))
|
|
16
20
|
|
|
17
21
|
|
|
18
|
-
def create_topic(name):
|
|
19
|
-
return post(routes.topics(), json={"name": name}, action="Create topic")
|
|
22
|
+
def create_topic(name) -> RemoteTopic:
|
|
23
|
+
return RemoteTopic.from_api(post(routes.topics(), json={"name": name}, action="Create topic"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_subtree(topic_id) -> list[RemoteTopic]:
|
|
27
|
+
"""Return ``RemoteTopic`` entries for the topic and all descendants."""
|
|
28
|
+
raw = get(routes.topic_subtree(topic_id), action="Fetch topic subtree", unwrap=True)
|
|
29
|
+
return [RemoteTopic.from_api(r) for r in raw]
|
|
20
30
|
|
|
21
31
|
|
|
22
32
|
def list_all_topics(**kwargs):
|
|
@@ -49,13 +59,21 @@ def list_all_knowledge(topic_id):
|
|
|
49
59
|
return list(iter_knowledge(topic_id))
|
|
50
60
|
|
|
51
61
|
|
|
52
|
-
def iter_knowledge(topic_id):
|
|
53
|
-
"""Yield knowledge items for a topic, page by page.
|
|
62
|
+
def iter_knowledge(topic_id, *, recursive=False):
|
|
63
|
+
"""Yield knowledge items for a topic, page by page.
|
|
64
|
+
|
|
65
|
+
When ``recursive`` is True, the server returns items from the topic
|
|
66
|
+
and all its descendants. Items carry ``topic_id`` so callers can
|
|
67
|
+
place them under the right subtopic.
|
|
68
|
+
"""
|
|
54
69
|
page = 1
|
|
70
|
+
base_params = {"page_size": 100}
|
|
71
|
+
if recursive:
|
|
72
|
+
base_params["recursive"] = "true"
|
|
55
73
|
while True:
|
|
56
74
|
data = get(
|
|
57
75
|
routes.topic_knowledge(topic_id),
|
|
58
|
-
params={"page": page
|
|
76
|
+
params={**base_params, "page": page},
|
|
59
77
|
action="List knowledge",
|
|
60
78
|
)
|
|
61
79
|
if isinstance(data, dict) and "results" in data:
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from obris.api.topics import create_topic, fetch_subtree, get_topic
|
|
6
|
+
from obris.output import as_json, is_json
|
|
7
|
+
from obris.sync.commands import add_file, link_file
|
|
8
|
+
from obris.sync.engine import run_sync
|
|
9
|
+
from obris.sync.state import SyncState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group("sync", invoke_without_command=True)
|
|
13
|
+
@click.option("--path", "-p", default=".", help="Local directory to sync (defaults to current directory)")
|
|
14
|
+
@click.option("--topic", "-t", "topic_id", default=None, help="Root topic ID to sync")
|
|
15
|
+
@click.option("--item", "-i", "item_ids", multiple=True, help="Specific item IDs to sync (repeatable)")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--include",
|
|
18
|
+
"include_patterns",
|
|
19
|
+
multiple=True,
|
|
20
|
+
help="Glob pattern for subtopics to include. Repeatable; patterns are OR'd together. "
|
|
21
|
+
"Supports ** for any depth, * ? [abc] within a segment. Example: 'Projects/**' or '**/skill-*'",
|
|
22
|
+
)
|
|
23
|
+
@click.option("--dry-run", is_flag=True, help="Preview changes without modifying anything")
|
|
24
|
+
@click.option("-y", "--yes", is_flag=True, help="Auto-confirm prompts")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def sync(ctx, path, topic_id, item_ids, include_patterns, dry_run, yes):
|
|
27
|
+
"""Sync a local directory with an Obris root topic and its subtopics.
|
|
28
|
+
|
|
29
|
+
On first sync, provide --topic <id> to link to an existing root topic,
|
|
30
|
+
or omit it to create a new root topic named after the directory.
|
|
31
|
+
Subsequent syncs auto-detect the linked topic from state.
|
|
32
|
+
|
|
33
|
+
Only root topics (topics with no parent) can be sync targets. Child
|
|
34
|
+
topics appear as subdirectories of their root. If you pass a child
|
|
35
|
+
topic ID, the command will tell you the root to use instead.
|
|
36
|
+
|
|
37
|
+
Use --include to sync only matching subtopics. Patterns OR together.
|
|
38
|
+
Use --item to sync specific items only.
|
|
39
|
+
"""
|
|
40
|
+
ctx.ensure_object(dict)
|
|
41
|
+
ctx.obj["path"] = path
|
|
42
|
+
ctx.obj["topic_id"] = topic_id
|
|
43
|
+
|
|
44
|
+
if ctx.invoked_subcommand is not None:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
sync_dir = Path(path).resolve()
|
|
48
|
+
patterns = list(include_patterns) if include_patterns else None
|
|
49
|
+
|
|
50
|
+
targets = _resolve_targets(sync_dir, topic_id, yes)
|
|
51
|
+
|
|
52
|
+
total_pulled, total_pushed, total_conflicts, total_errors = 0, 0, 0, 0
|
|
53
|
+
|
|
54
|
+
if dry_run:
|
|
55
|
+
click.echo(" (dry run — no changes will be made)")
|
|
56
|
+
|
|
57
|
+
for state, tid, topic_name in targets:
|
|
58
|
+
# Root-only enforcement: fetch the topic and check parent_id.
|
|
59
|
+
topic = get_topic(tid)
|
|
60
|
+
if topic.parent_id:
|
|
61
|
+
root = _find_root(tid)
|
|
62
|
+
raise SystemExit(
|
|
63
|
+
f'"{topic_name}" is a subtopic of "{root.name}". '
|
|
64
|
+
f"Sync from the root topic instead:\n"
|
|
65
|
+
f" obris sync --topic {root.id} --path {sync_dir}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
click.echo(f'Syncing "{topic_name}" \u2194 {sync_dir}/')
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
subtree = fetch_subtree(tid)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
click.echo(f" Error fetching subtree: {type(e).__name__}: {e}", err=True)
|
|
74
|
+
total_errors += 1
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
pulled, pushed, conflicts, errors = run_sync(
|
|
78
|
+
tid,
|
|
79
|
+
str(sync_dir),
|
|
80
|
+
state=state,
|
|
81
|
+
subtree=subtree,
|
|
82
|
+
item_ids=item_ids or None,
|
|
83
|
+
include_patterns=patterns,
|
|
84
|
+
dry_run=dry_run,
|
|
85
|
+
)
|
|
86
|
+
total_pulled += pulled
|
|
87
|
+
total_pushed += pushed
|
|
88
|
+
total_conflicts += conflicts
|
|
89
|
+
total_errors += errors
|
|
90
|
+
|
|
91
|
+
if not pulled and not pushed and not conflicts and not errors:
|
|
92
|
+
click.echo(" Everything up to date.")
|
|
93
|
+
else:
|
|
94
|
+
parts = []
|
|
95
|
+
if pushed:
|
|
96
|
+
parts.append(f"{pushed} pushed")
|
|
97
|
+
if pulled:
|
|
98
|
+
parts.append(f"{pulled} pulled")
|
|
99
|
+
if conflicts:
|
|
100
|
+
parts.append(f"{conflicts} conflicts")
|
|
101
|
+
if errors:
|
|
102
|
+
parts.append(f"{errors} failed")
|
|
103
|
+
click.echo(f" Done: {', '.join(parts)}")
|
|
104
|
+
|
|
105
|
+
if is_json():
|
|
106
|
+
as_json(
|
|
107
|
+
{
|
|
108
|
+
"path": str(sync_dir),
|
|
109
|
+
"pulled": total_pulled,
|
|
110
|
+
"pushed": total_pushed,
|
|
111
|
+
"conflicts": total_conflicts,
|
|
112
|
+
"errors": total_errors,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if total_errors:
|
|
117
|
+
raise SystemExit(1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@sync.command("add")
|
|
121
|
+
@click.argument("file")
|
|
122
|
+
@click.option("--topic", "-t", "topic_id", default=None, help="Target topic ID (root or subtopic)")
|
|
123
|
+
def sync_add(file, topic_id):
|
|
124
|
+
"""Add a local file to a synced topic.
|
|
125
|
+
|
|
126
|
+
FILE is the path to the file to add.
|
|
127
|
+
|
|
128
|
+
If the directory is synced to exactly one root topic, that root is
|
|
129
|
+
used automatically. Provide --topic to specify a subtopic of that
|
|
130
|
+
root to place the item under.
|
|
131
|
+
"""
|
|
132
|
+
filepath = Path(file).resolve()
|
|
133
|
+
if not filepath.exists():
|
|
134
|
+
raise SystemExit(f"File not found: {filepath}")
|
|
135
|
+
|
|
136
|
+
sync_dir = filepath.parent
|
|
137
|
+
states = SyncState.find_all_for_path(sync_dir)
|
|
138
|
+
|
|
139
|
+
if not states and not topic_id:
|
|
140
|
+
raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
|
|
141
|
+
|
|
142
|
+
# Determine the root topic (state key). Explicit topic_id may be a root
|
|
143
|
+
# OR a subtopic under one of the known roots.
|
|
144
|
+
if states and topic_id:
|
|
145
|
+
# User passed an explicit topic — figure out which root it belongs to.
|
|
146
|
+
target_topic = get_topic(topic_id)
|
|
147
|
+
root_id = _find_root_id(topic_id, target_topic)
|
|
148
|
+
root_state_match = next(((s, rid) for s, rid in states if rid == root_id), None)
|
|
149
|
+
if root_state_match is None:
|
|
150
|
+
raise SystemExit(f"Topic {topic_id} is under root {root_id}, which is not synced at {sync_dir}/.")
|
|
151
|
+
_root_state, root_id = root_state_match
|
|
152
|
+
target_topic_id = topic_id
|
|
153
|
+
topic_name = target_topic.name
|
|
154
|
+
elif states:
|
|
155
|
+
if len(states) > 1:
|
|
156
|
+
click.echo(f"Multiple root topics sync to {sync_dir}/. Specify which with --topic <id>:")
|
|
157
|
+
for _state, tid in states:
|
|
158
|
+
topic = get_topic(tid)
|
|
159
|
+
click.echo(f" {topic.name} ({tid})")
|
|
160
|
+
raise SystemExit(1)
|
|
161
|
+
_root_state, root_id = states[0]
|
|
162
|
+
target_topic_id = root_id
|
|
163
|
+
topic_name = get_topic(root_id).name
|
|
164
|
+
else:
|
|
165
|
+
# No existing sync state but user passed --topic. Must be a root.
|
|
166
|
+
target_topic = get_topic(topic_id)
|
|
167
|
+
if target_topic.parent_id:
|
|
168
|
+
raise SystemExit(
|
|
169
|
+
f"\"{target_topic.name}\" is a subtopic. Run 'obris sync --topic <root-id>' first to set up the sync."
|
|
170
|
+
)
|
|
171
|
+
root_id = topic_id
|
|
172
|
+
target_topic_id = topic_id
|
|
173
|
+
topic_name = target_topic.name
|
|
174
|
+
|
|
175
|
+
item = add_file(root_id, str(sync_dir), str(filepath), target_topic_id=target_topic_id)
|
|
176
|
+
|
|
177
|
+
if is_json():
|
|
178
|
+
as_json(item)
|
|
179
|
+
else:
|
|
180
|
+
click.echo(f'Added "{filepath.name}" to "{topic_name}"')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@sync.command("link")
|
|
184
|
+
@click.argument("file")
|
|
185
|
+
@click.option("--item", "-i", "item_id", required=True, help="Knowledge item ID to link to")
|
|
186
|
+
@click.option("--topic", "-t", "topic_id", default=None, help="Target topic ID (root or subtopic)")
|
|
187
|
+
def sync_link(file, item_id, topic_id):
|
|
188
|
+
"""Link a local file to an existing remote item.
|
|
189
|
+
|
|
190
|
+
Use after renaming a file locally to reattach it to its remote item.
|
|
191
|
+
"""
|
|
192
|
+
filepath = Path(file).resolve()
|
|
193
|
+
if not filepath.exists():
|
|
194
|
+
raise SystemExit(f"File not found: {filepath}")
|
|
195
|
+
|
|
196
|
+
sync_dir = filepath.parent
|
|
197
|
+
states = SyncState.find_all_for_path(sync_dir)
|
|
198
|
+
|
|
199
|
+
if not states:
|
|
200
|
+
raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync -t <id>' first.")
|
|
201
|
+
|
|
202
|
+
if topic_id:
|
|
203
|
+
target_topic = get_topic(topic_id)
|
|
204
|
+
root_id = _find_root_id(topic_id, target_topic)
|
|
205
|
+
match = next(((s, rid) for s, rid in states if rid == root_id), None)
|
|
206
|
+
if match is None:
|
|
207
|
+
raise SystemExit(f"Topic {topic_id} is under root {root_id}, which is not synced at {sync_dir}/.")
|
|
208
|
+
_, root_id = match
|
|
209
|
+
target_topic_id = topic_id
|
|
210
|
+
else:
|
|
211
|
+
if len(states) > 1:
|
|
212
|
+
click.echo(f"Multiple root topics sync to {sync_dir}/. Specify which with -t <id>:")
|
|
213
|
+
for _state, tid in states:
|
|
214
|
+
topic = get_topic(tid)
|
|
215
|
+
click.echo(f" {topic.name} ({tid})")
|
|
216
|
+
raise SystemExit(1)
|
|
217
|
+
_, root_id = states[0]
|
|
218
|
+
target_topic_id = root_id
|
|
219
|
+
|
|
220
|
+
link_file(root_id, str(sync_dir), str(filepath), item_id, target_topic_id=target_topic_id)
|
|
221
|
+
click.echo(f'Linked "{filepath.name}" to item {item_id}')
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resolve_targets(sync_dir, topic_id, yes):
|
|
225
|
+
"""Determine which topic(s) to sync.
|
|
226
|
+
|
|
227
|
+
Returns list of (SyncState | None, topic_id, topic_name) tuples.
|
|
228
|
+
"""
|
|
229
|
+
if topic_id:
|
|
230
|
+
state = SyncState.load(topic_id, sync_dir)
|
|
231
|
+
topic = get_topic(topic_id)
|
|
232
|
+
return [(state, topic_id, topic.name)]
|
|
233
|
+
|
|
234
|
+
states = SyncState.find_all_for_path(sync_dir)
|
|
235
|
+
|
|
236
|
+
if states:
|
|
237
|
+
targets = []
|
|
238
|
+
for state, tid in states:
|
|
239
|
+
topic = get_topic(tid)
|
|
240
|
+
targets.append((state, tid, topic.name))
|
|
241
|
+
return targets
|
|
242
|
+
|
|
243
|
+
topic_name = sync_dir.name
|
|
244
|
+
if not yes:
|
|
245
|
+
click.confirm(f'Create topic "{topic_name}" and sync to {sync_dir}/?', default=True, abort=True)
|
|
246
|
+
topic = create_topic(topic_name)
|
|
247
|
+
click.echo(f'Created topic "{topic_name}"')
|
|
248
|
+
return [(None, topic.id, topic_name)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _find_root(topic_id, topic=None):
|
|
252
|
+
"""Walk up parent_id pointers to find the root topic.
|
|
253
|
+
|
|
254
|
+
Cycle-safe: visited set + depth cap. Raises SystemExit on cycle.
|
|
255
|
+
"""
|
|
256
|
+
if topic is None:
|
|
257
|
+
topic = get_topic(topic_id)
|
|
258
|
+
visited = {topic_id}
|
|
259
|
+
depth = 0
|
|
260
|
+
while topic.parent_id:
|
|
261
|
+
depth += 1
|
|
262
|
+
if depth > 64:
|
|
263
|
+
raise SystemExit(f"Topic ancestor chain exceeds depth 64 starting at {topic_id}")
|
|
264
|
+
parent_id = topic.parent_id
|
|
265
|
+
if parent_id in visited:
|
|
266
|
+
raise SystemExit(f"Topic ancestor cycle detected walking up from {topic_id}")
|
|
267
|
+
visited.add(parent_id)
|
|
268
|
+
topic = get_topic(parent_id)
|
|
269
|
+
return topic
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _find_root_id(topic_id, topic=None):
|
|
273
|
+
return _find_root(topic_id, topic).id
|
|
@@ -9,18 +9,22 @@ from .mapping import hash_file, now_iso, read_if_text
|
|
|
9
9
|
from .state import SyncState
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def add_file(
|
|
13
|
-
"""Add a local file to a topic and start tracking it.
|
|
12
|
+
def add_file(root_topic_id, sync_dir, filepath, *, target_topic_id=None):
|
|
13
|
+
"""Add a local file to a topic (root or subtopic) and start tracking it.
|
|
14
|
+
|
|
15
|
+
``root_topic_id`` is the root of the sync tree (state key). ``target_topic_id``
|
|
16
|
+
is the topic the item is actually created under — defaults to the root.
|
|
14
17
|
|
|
15
18
|
Returns the created knowledge item dict.
|
|
16
19
|
"""
|
|
17
20
|
sync_dir = Path(sync_dir)
|
|
18
21
|
filepath = Path(filepath)
|
|
22
|
+
target_topic_id = target_topic_id or root_topic_id
|
|
19
23
|
|
|
20
24
|
if not filepath.exists():
|
|
21
25
|
raise FileNotFoundError(f"File not found: {filepath}")
|
|
22
26
|
|
|
23
|
-
state = SyncState.load(
|
|
27
|
+
state = SyncState.load(root_topic_id, sync_dir) or SyncState(root_topic_id, sync_dir)
|
|
24
28
|
|
|
25
29
|
filename = filepath.name
|
|
26
30
|
existing_kid, _ = state.find_by_filename(filename)
|
|
@@ -30,32 +34,33 @@ def add_file(topic_id, sync_dir, filepath):
|
|
|
30
34
|
file_hash = hash_file(filepath)
|
|
31
35
|
|
|
32
36
|
content, is_text = read_if_text(filepath)
|
|
33
|
-
if is_text:
|
|
34
|
-
result = knowledge_add(
|
|
37
|
+
if is_text:
|
|
38
|
+
result = knowledge_add(target_topic_id, filename, content)
|
|
35
39
|
else:
|
|
36
|
-
result = upload_file(
|
|
40
|
+
result = upload_file(target_topic_id, filepath, filename)
|
|
37
41
|
|
|
38
42
|
synced_at = result.get("updated_at") or result.get("created_at") or now_iso()
|
|
39
|
-
state.track(result["id"], filename, file_hash, synced_at)
|
|
43
|
+
state.track(result["id"], filename, file_hash, synced_at, topic_id=target_topic_id)
|
|
40
44
|
state.save()
|
|
41
45
|
|
|
42
46
|
return result
|
|
43
47
|
|
|
44
48
|
|
|
45
|
-
def link_file(
|
|
49
|
+
def link_file(root_topic_id, sync_dir, filepath, knowledge_id, *, target_topic_id=None):
|
|
46
50
|
"""Link a local file to an existing remote item.
|
|
47
51
|
|
|
48
52
|
Use after renaming a local file to reattach it to its remote item.
|
|
49
53
|
"""
|
|
50
54
|
sync_dir = Path(sync_dir)
|
|
51
55
|
filepath = Path(filepath)
|
|
56
|
+
target_topic_id = target_topic_id or root_topic_id
|
|
52
57
|
|
|
53
58
|
if not filepath.exists():
|
|
54
59
|
raise FileNotFoundError(f"File not found: {filepath}")
|
|
55
60
|
|
|
56
|
-
state = SyncState.load(
|
|
61
|
+
state = SyncState.load(root_topic_id, sync_dir) or SyncState(root_topic_id, sync_dir)
|
|
57
62
|
|
|
58
63
|
filename = filepath.name
|
|
59
64
|
file_hash = hash_file(filepath)
|
|
60
|
-
state.track(knowledge_id, filename, file_hash, now_iso())
|
|
65
|
+
state.track(knowledge_id, filename, file_hash, now_iso(), topic_id=target_topic_id)
|
|
61
66
|
state.save()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Bidirectional sync engine package.
|
|
2
|
+
|
|
3
|
+
Public entry point is ``run_sync``. Internals are split for readability:
|
|
4
|
+
|
|
5
|
+
- ``run.py`` — main sync loop (fetch + dispatch + reconcile)
|
|
6
|
+
- ``subtree.py`` — name-path / ancestor walking and directory reconciliation
|
|
7
|
+
- ``tracked.py`` — per-item sync for items already present in state
|
|
8
|
+
- ``filters.py`` — glob pattern matching for ``--include``
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .run import run_sync
|
|
12
|
+
|
|
13
|
+
__all__ = ["run_sync"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Glob-style include-pattern matching for sync.
|
|
2
|
+
|
|
3
|
+
Patterns match against a topic's name path from the root, slash-joined.
|
|
4
|
+
For example, a topic ``Projects/Obris/Sync`` has path segments
|
|
5
|
+
``["Projects", "Obris", "Sync"]``.
|
|
6
|
+
|
|
7
|
+
Semantics:
|
|
8
|
+
- ``*``, ``?``, ``[abc]`` are intra-segment wildcards (fnmatch-style).
|
|
9
|
+
They match within a single segment and never cross ``/``.
|
|
10
|
+
- ``**`` is the cross-segment wildcard — zero or more segments.
|
|
11
|
+
- Matching is case-insensitive.
|
|
12
|
+
- A topic matches if *any* of the supplied patterns matches its path.
|
|
13
|
+
|
|
14
|
+
Examples (root is ``Work``):
|
|
15
|
+
- ``Projects/**`` → ``Projects``, ``Projects/Obris``, ``Projects/Obris/Sync``
|
|
16
|
+
- ``**/skill-*`` → any topic whose leaf starts with ``skill-``
|
|
17
|
+
- ``Archive/2024`` → exact match
|
|
18
|
+
- ``Sk*/skill-py*`` → ``Skills/skill-python``
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import fnmatch
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _split_pattern(pattern: str) -> list[str]:
|
|
27
|
+
return [seg for seg in pattern.split("/") if seg != ""]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _match_segments(path: list[str], pat: list[str]) -> bool:
|
|
31
|
+
"""Recursive matcher supporting ``**`` as zero-or-more segments."""
|
|
32
|
+
if not pat:
|
|
33
|
+
return not path
|
|
34
|
+
head, *rest = pat
|
|
35
|
+
if head == "**":
|
|
36
|
+
if _match_segments(path, rest):
|
|
37
|
+
return True
|
|
38
|
+
if not path:
|
|
39
|
+
return False
|
|
40
|
+
return _match_segments(path[1:], pat)
|
|
41
|
+
if not path:
|
|
42
|
+
return False
|
|
43
|
+
if fnmatch.fnmatchcase(path[0].lower(), head.lower()):
|
|
44
|
+
return _match_segments(path[1:], rest)
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def match_path(path_segments: list[str], pattern: str) -> bool:
|
|
49
|
+
"""Return True if the topic path matches the single pattern."""
|
|
50
|
+
return _match_segments(list(path_segments), _split_pattern(pattern))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def any_match(path_segments: list[str], patterns: list[str]) -> bool:
|
|
54
|
+
"""Return True if any pattern matches the topic path.
|
|
55
|
+
|
|
56
|
+
Callers must special-case empty ``patterns`` themselves — an empty
|
|
57
|
+
pattern list means "no filter configured", which is a policy
|
|
58
|
+
decision (match everything) that shouldn't be baked into the
|
|
59
|
+
matcher semantics. Strict semantics: ``any([...])`` is False for
|
|
60
|
+
empty input, and that's what this returns.
|
|
61
|
+
"""
|
|
62
|
+
return any(match_path(path_segments, p) for p in patterns)
|