diff --git a/fns_cli/file_sync.py b/fns_cli/file_sync.py index 9a25101..f152f04 100644 --- a/fns_cli/file_sync.py +++ b/fns_cli/file_sync.py @@ -500,9 +500,10 @@ def _collect_local_files(self) -> list[dict]: rel = fp.relative_to(self.vault_path).as_posix() if self.engine.is_excluded(rel) or rel.endswith(".md"): continue - if rel.startswith(".obsidian/") and not self.config.sync.sync_config: + first = rel.split("/")[0] + if first.startswith(".") and not self.config.sync.sync_config: continue - if not rel.startswith(".obsidian/") and not self.config.sync.sync_files: + if not first.startswith(".") and not self.config.sync.sync_files: continue try: stat = fp.stat() diff --git a/fns_cli/protocol.py b/fns_cli/protocol.py index 9e426a4..640ee1b 100644 --- a/fns_cli/protocol.py +++ b/fns_cli/protocol.py @@ -30,6 +30,10 @@ ACTION_FOLDER_DELETE = "FolderDelete" ACTION_FOLDER_RENAME = "FolderRename" +ACTION_SETTING_SYNC = "SettingSync" +ACTION_SETTING_MODIFY = "SettingModify" +ACTION_SETTING_DELETE = "SettingDelete" + # ── Server → Client actions ────────────────────────────────────────── ACTION_NOTE_SYNC_MODIFY = "NoteSyncModify" ACTION_NOTE_SYNC_DELETE = "NoteSyncDelete" @@ -47,6 +51,13 @@ ACTION_FILE_UPLOAD_ACK = "FileUploadAck" ACTION_FILE_SYNC_END = "FileSyncEnd" +ACTION_SETTING_SYNC_MODIFY = "SettingSyncModify" +ACTION_SETTING_SYNC_DELETE = "SettingSyncDelete" +ACTION_SETTING_SYNC_RENAME = "SettingSyncRename" +ACTION_SETTING_SYNC_MTIME = "SettingSyncMtime" +ACTION_SETTING_SYNC_NEED_UPLOAD = "SettingSyncNeedUpload" +ACTION_SETTING_SYNC_END = "SettingSyncEnd" + ACTION_FOLDER_SYNC_MODIFY = "FolderSyncModify" ACTION_FOLDER_SYNC_DELETE = "FolderSyncDelete" ACTION_FOLDER_SYNC_RENAME = "FolderSyncRename" diff --git a/fns_cli/setting_sync.py b/fns_cli/setting_sync.py new file mode 100644 index 0000000..2c06101 --- /dev/null +++ b/fns_cli/setting_sync.py @@ -0,0 +1,339 @@ +"""Setting (config) sync protocol: SettingSync incremental pull + SettingModify/SettingDelete push.""" + +from __future__ import annotations + +import logging +import os +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +from .hash_utils import file_content_hash_binary, path_hash +from .protocol import ( + ACTION_SETTING_DELETE, + ACTION_SETTING_MODIFY, + ACTION_SETTING_SYNC, + ACTION_SETTING_SYNC_DELETE, + ACTION_SETTING_SYNC_END, + ACTION_SETTING_SYNC_MODIFY, + ACTION_SETTING_SYNC_MTIME, + ACTION_SETTING_SYNC_NEED_UPLOAD, + ACTION_SETTING_SYNC_RENAME, + WSMessage, +) + +if TYPE_CHECKING: + from .sync_engine import SyncEngine + +log = logging.getLogger("fns_cli.setting_sync") + +_DELETED = "__deleted__" + + +def _extract_inner(msg_data: dict) -> dict: + """Server wraps payloads as {code, status, message, data: {actual fields}}.""" + if isinstance(msg_data, dict) and "data" in msg_data: + inner = msg_data["data"] + if isinstance(inner, dict): + return inner + return msg_data if isinstance(msg_data, dict) else {} + + +def _is_config_path(rel: str) -> bool: + """Check whether a relative path belongs to config/settings scope. + + This matches the Obsidian plugin behaviour: anything inside a dot-prefixed + directory (e.g. .obsidian, .agents) is treated as a setting file. + Standard exclusions (.git, .trash) are handled by is_excluded() upstream. + """ + first = rel.split("/")[0] + return first.startswith(".") + + +class SettingSync: + def __init__(self, engine: SyncEngine) -> None: + self.engine = engine + self.config = engine.config + self.vault_path = engine.vault_path + self._sync_complete = False + self._expected_modify = 0 + self._expected_delete = 0 + self._received_modify = 0 + self._received_delete = 0 + self._got_end = False + self._pending_last_time = 0 + self._echo_hashes: dict[str, str] = {} + + @property + def is_sync_complete(self) -> bool: + return self._sync_complete + + def register_handlers(self) -> None: + ws = self.engine.ws_client + ws.on(ACTION_SETTING_SYNC_MODIFY, self._on_sync_modify) + ws.on(ACTION_SETTING_SYNC_DELETE, self._on_sync_delete) + ws.on(ACTION_SETTING_SYNC_RENAME, self._on_sync_rename) + ws.on(ACTION_SETTING_SYNC_MTIME, self._on_sync_mtime) + ws.on(ACTION_SETTING_SYNC_NEED_UPLOAD, self._on_sync_need_upload) + ws.on(ACTION_SETTING_SYNC_END, self._on_sync_end) + + async def request_sync(self) -> None: + """Send incremental SettingSync request.""" + self._reset_counters() + last_time = self.engine.state.last_setting_sync_time + ctx = str(uuid.uuid4()) + settings = self._collect_local_settings() + msg = WSMessage(ACTION_SETTING_SYNC, { + "context": ctx, + "vault": self.config.server.vault, + "lastTime": last_time, + "settings": settings, + }) + log.info("Requesting SettingSync (lastTime=%d, localSettings=%d)", last_time, len(settings)) + await self.engine.ws_client.send(msg) + + async def request_full_sync(self) -> None: + """Full sync: send all local settings for comparison.""" + self._reset_counters() + settings = self._collect_local_settings() + ctx = str(uuid.uuid4()) + msg = WSMessage(ACTION_SETTING_SYNC, { + "context": ctx, + "vault": self.config.server.vault, + "lastTime": 0, + "settings": settings, + }) + log.info("Requesting full SettingSync with %d local settings", len(settings)) + await self.engine.ws_client.send(msg) + + async def push_modify(self, rel_path: str, *, force: bool = False) -> None: + full = self.vault_path / rel_path + if not full.exists(): + return + + hash_ = file_content_hash_binary(full) + if not force and self._echo_hashes.get(rel_path) == hash_: + return + + try: + text = full.read_text(encoding="utf-8") + except UnicodeDecodeError: + # Some config files (e.g. binary plugins) may not be text; skip for now. + log.warning("Skipping non-text setting file: %s", rel_path) + return + except Exception: + log.exception("Failed to read %s", rel_path) + return + + stat = full.stat() + msg = WSMessage(ACTION_SETTING_MODIFY, { + "vault": self.config.server.vault, + "path": rel_path, + "pathHash": path_hash(rel_path), + "content": text, + "contentHash": hash_, + "ctime": int(stat.st_ctime * 1000), + "mtime": int(stat.st_mtime * 1000), + }) + log.info("SettingModify → %s", rel_path) + await self.engine.ws_client.send(msg) + self._echo_hashes[rel_path] = hash_ + + async def push_delete(self, rel_path: str) -> None: + if self._echo_hashes.get(rel_path) == _DELETED: + return + msg = WSMessage(ACTION_SETTING_DELETE, { + "vault": self.config.server.vault, + "path": rel_path, + "pathHash": path_hash(rel_path), + }) + log.info("SettingDelete → %s", rel_path) + await self.engine.ws_client.send(msg) + self._echo_hashes[rel_path] = _DELETED + + async def push_rename(self, new_rel: str, old_rel: str) -> None: + await self.push_modify(new_rel) + await self.push_delete(old_rel) + + # ── Server → Client handlers ───────────────────────────────────── + + async def _on_sync_modify(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + rel_path: str = data.get("path", "") + content: str = data.get("content", "") + mtime = data.get("mtime", 0) + + if not rel_path: + return + + full = self.vault_path / rel_path + try: + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + if mtime: + ts = mtime / 1000.0 + os.utime(full, (ts, ts)) + self._echo_hashes[rel_path] = file_content_hash_binary(full) + log.info("← SettingSyncModify: %s", rel_path) + except Exception: + log.exception("Failed to write setting %s", rel_path) + + self._received_modify += 1 + self._check_all_received() + + async def _on_sync_delete(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + rel_path: str = data.get("path", "") + if not rel_path: + return + + full = self.vault_path / rel_path + try: + if full.exists(): + full.unlink() + log.info("← SettingSyncDelete: %s", rel_path) + self._try_remove_empty_parent(full) + self._echo_hashes[rel_path] = _DELETED + except Exception: + log.exception("Failed to delete setting %s", rel_path) + + self._received_delete += 1 + self._check_all_received() + + async def _on_sync_rename(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + old_path: str = data.get("oldPath", "") + new_path: str = data.get("path", "") + if not old_path or not new_path: + return + + self._echo_hashes[old_path] = _DELETED + old_full = self.vault_path / old_path + new_full = self.vault_path / new_path + try: + if old_full.exists(): + new_full.parent.mkdir(parents=True, exist_ok=True) + old_full.rename(new_full) + if new_full.exists(): + self._echo_hashes[new_path] = file_content_hash_binary(new_full) + log.info("← SettingSyncRename: %s → %s", old_path, new_path) + self._try_remove_empty_parent(old_full) + except Exception: + log.exception("Failed to rename setting %s → %s", old_path, new_path) + + async def _on_sync_mtime(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + rel_path: str = data.get("path", "") + mtime = data.get("mtime", 0) + if not rel_path or not mtime: + return + full = self.vault_path / rel_path + if full.exists(): + try: + ts = mtime / 1000.0 + os.utime(full, (ts, ts)) + except OSError: + pass + + async def _on_sync_need_upload(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + need_upload = data.get("needUpload", []) + if not isinstance(need_upload, list) or not need_upload: + return + log.info("← SettingSyncNeedUpload: %d files", len(need_upload)) + for item in need_upload: + rel_path = item.get("path", "") if isinstance(item, dict) else str(item) + if rel_path: + await self.push_modify(rel_path) + + async def _on_sync_end(self, msg: WSMessage) -> None: + data = _extract_inner(msg.data) + last_time = data.get("lastTime", 0) + self._expected_modify = data.get("needModifyCount", 0) + self._expected_delete = data.get("needDeleteCount", 0) + self._pending_last_time = last_time + self._got_end = True + log.info( + "← SettingSyncEnd (lastTime=%d, needModify=%d, needDelete=%d, needUpload=%d)", + last_time, + self._expected_modify, + self._expected_delete, + data.get("needUploadCount", 0), + ) + + total_expected = self._expected_modify + self._expected_delete + if total_expected == 0: + self._sync_complete = True + self._commit_last_time() + else: + self._check_all_received() + + # ── Internal helpers ───────────────────────────────────────────── + + def _reset_counters(self) -> None: + self._sync_complete = False + self._got_end = False + self._expected_modify = 0 + self._expected_delete = 0 + self._received_modify = 0 + self._received_delete = 0 + self._pending_last_time = 0 + + def _check_all_received(self) -> None: + if not self._got_end: + return + total_expected = self._expected_modify + self._expected_delete + total_received = self._received_modify + self._received_delete + if total_received >= total_expected: + log.info( + "SettingSync complete: %d modified, %d deleted", + self._received_modify, + self._received_delete, + ) + self._sync_complete = True + self._commit_last_time() + + def _commit_last_time(self) -> None: + if self._pending_last_time: + log.info("Committing setting lastTime=%d", self._pending_last_time) + self.engine.state.last_setting_sync_time = self._pending_last_time + self.engine.state.save() + self._pending_last_time = 0 + + def _try_remove_empty_parent(self, file_path: Path) -> None: + parent = file_path.parent + while parent != self.vault_path: + try: + if parent.exists() and not any(parent.iterdir()): + parent.rmdir() + else: + break + except OSError: + break + parent = parent.parent + + def _collect_local_settings(self) -> list[dict]: + """Collect all config files under dot-prefixed directories.""" + settings = [] + for fp in self.vault_path.rglob("*"): + if fp.is_dir(): + continue + rel = fp.relative_to(self.vault_path).as_posix() + if self.engine.is_excluded(rel): + continue + if not _is_config_path(rel): + continue + try: + hash_ = file_content_hash_binary(fp) + except Exception: + continue + stat = fp.stat() + settings.append({ + "path": rel, + "pathHash": path_hash(rel), + "contentHash": hash_, + "mtime": int(stat.st_mtime * 1000), + "ctime": int(stat.st_ctime * 1000), + "size": stat.st_size, + }) + return settings diff --git a/fns_cli/state.py b/fns_cli/state.py index 6425386..e5816a0 100644 --- a/fns_cli/state.py +++ b/fns_cli/state.py @@ -13,6 +13,7 @@ class SyncState: last_note_sync_time: int = 0 last_file_sync_time: int = 0 + last_setting_sync_time: int = 0 _path: str = field(default="", repr=False) @@ -32,6 +33,7 @@ def load(cls, vault_dir: Path) -> SyncState: raw = json.loads(p.read_text(encoding="utf-8")) state.last_note_sync_time = raw.get("last_note_sync_time", 0) state.last_file_sync_time = raw.get("last_file_sync_time", 0) + state.last_setting_sync_time = raw.get("last_setting_sync_time", 0) except (json.JSONDecodeError, OSError): pass return state diff --git a/fns_cli/sync_engine.py b/fns_cli/sync_engine.py index 7a53fa3..137af03 100644 --- a/fns_cli/sync_engine.py +++ b/fns_cli/sync_engine.py @@ -12,6 +12,7 @@ from .file_sync import FileSync from .folder_sync import FolderSync from .note_sync import NoteSync +from .setting_sync import SettingSync from .state import SyncState log = logging.getLogger("fns_cli.sync_engine") @@ -26,6 +27,7 @@ def __init__(self, config: AppConfig) -> None: self.note_sync = NoteSync(self) self.file_sync = FileSync(self) self.folder_sync = FolderSync(self) + self.setting_sync = SettingSync(self) self._ignored_files: set[str] = set() self._watch_enabled = False @@ -50,13 +52,14 @@ def _is_note(self, rel_path: str) -> bool: return rel_path.endswith(".md") def _is_config(self, rel_path: str) -> bool: - return rel_path.startswith(".obsidian/") + first = rel_path.split("/")[0] + return first.startswith(".") def _should_sync_file(self, rel_path: str) -> bool: - if self._is_note(rel_path): - return self.config.sync.sync_notes if self._is_config(rel_path): return self.config.sync.sync_config + if self._is_note(rel_path): + return self.config.sync.sync_notes return self.config.sync.sync_files # ── Local change callbacks (from watcher) ──────────────────────── @@ -64,7 +67,9 @@ def _should_sync_file(self, rel_path: str) -> bool: async def on_local_change(self, rel_path: str) -> None: if not self._watch_enabled or not self._should_sync_file(rel_path): return - if self._is_note(rel_path): + if self._is_config(rel_path): + await self.setting_sync.push_modify(rel_path) + elif self._is_note(rel_path): await self.note_sync.push_modify(rel_path) else: await self.file_sync.push_upload(rel_path) @@ -72,7 +77,9 @@ async def on_local_change(self, rel_path: str) -> None: async def on_local_delete(self, rel_path: str) -> None: if not self._watch_enabled or not self._should_sync_file(rel_path): return - if self._is_note(rel_path): + if self._is_config(rel_path): + await self.setting_sync.push_delete(rel_path) + elif self._is_note(rel_path): await self.note_sync.push_delete(rel_path) else: await self.file_sync.push_delete(rel_path) @@ -80,7 +87,9 @@ async def on_local_delete(self, rel_path: str) -> None: async def on_local_rename(self, new_rel: str, old_rel: str) -> None: if not self._watch_enabled: return - if self._is_note(new_rel): + if self._is_config(new_rel): + await self.setting_sync.push_rename(new_rel, old_rel) + elif self._is_note(new_rel): await self.note_sync.push_rename(new_rel, old_rel) else: await self.file_sync.push_delete(old_rel) @@ -92,6 +101,7 @@ def _register_handlers(self) -> None: self.note_sync.register_handlers() self.file_sync.register_handlers() self.folder_sync.register_handlers() + self.setting_sync.register_handlers() async def run(self) -> None: """Connect, do initial sync, then watch for changes indefinitely.""" @@ -151,6 +161,10 @@ async def sync_once(self) -> None: if self.config.sync.sync_files or self.config.sync.sync_config: await self.file_sync.request_sync() await self._wait_file_sync(timeout=300) + + if self.config.sync.sync_config: + await self.setting_sync.request_full_sync() + await self._wait_setting_sync(timeout=120) finally: await self.ws_client.close() ws_task.cancel() @@ -198,6 +212,9 @@ async def push(self) -> None: if self.config.sync.sync_files: await self._push_all_files() + + if self.config.sync.sync_config: + await self._push_all_settings() finally: await self.ws_client.close() ws_task.cancel() @@ -217,6 +234,10 @@ async def _initial_sync(self) -> None: await self.file_sync.request_sync() await self._wait_file_sync(timeout=300) + if self.config.sync.sync_config: + await self.setting_sync.request_sync() + await self._wait_setting_sync(timeout=300) + async def _wait_note_sync(self, timeout: float = 60) -> None: loop = asyncio.get_running_loop() deadline = loop.time() + timeout @@ -253,6 +274,15 @@ async def _wait_file_sync(self, timeout: float = 60) -> None: break await asyncio.sleep(0.5) + async def _wait_setting_sync(self, timeout: float = 60) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not self.setting_sync.is_sync_complete: + if loop.time() > deadline: + log.warning("SettingSync timed out after %.0fs", timeout) + break + await asyncio.sleep(0.5) + async def _push_all_files(self) -> None: """Upload every non-note, non-excluded file in the vault.""" for fp in self.vault_path.rglob("*"): @@ -267,3 +297,14 @@ async def _push_all_files(self) -> None: continue await self.file_sync.push_upload(rel) await asyncio.sleep(0.05) + + async def _push_all_settings(self) -> None: + """Upload every config file in dot-prefixed directories.""" + for fp in self.vault_path.rglob("*"): + if fp.is_dir(): + continue + rel = fp.relative_to(self.vault_path).as_posix() + if self.is_excluded(rel) or not self._is_config(rel): + continue + await self.setting_sync.push_modify(rel) + await asyncio.sleep(0.05)