diff --git a/README.md b/README.md index 8379efa..ae28abd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ - **Series Manager** — Multi-episode/season management with a shared series bible (characters, locations, props, themes, backstory) - **Git Snapshots** — Local version control with create, list, and restore - **Remote Sync** — Push/pull to GitHub, GitLab, or Codeberg -- **Cloud Export** — Export to Google Drive, iCloud, Dropbox, OneDrive, or Nextcloud +- **Cloud Export** — Export scaffolding for Google Drive, iCloud, Dropbox, OneDrive, and Nextcloud (provider SDKs not yet integrated) - **Share for Review** — Generate self-contained, watermarked HTML files for confidential review - **Real-time Collaboration** — LAN-based peer-to-peer editing with CRDT conflict resolution and presence indicators - **Plugin Marketplace** — Browse, install, and manage community plugins with a built-in marketplace UI @@ -153,6 +153,7 @@ Use **Import Script** from the Dashboard or Command Palette to open `.fountain` - **Python 3.12+** (recommended; 3.10+ supported) - **Rust 1.70+** (for the native core) +- **maturin** (`pip install maturin`) - **Qt 6** (installed via PySide6) ### Setup @@ -169,9 +170,9 @@ source .venv/bin/activate # or .venv\Scripts\activate on Windows # Install Python dependencies pip install -e ".[dev]" -# Build the Rust core +# Build the Rust core (PyO3 extension) cd src-tauri -cargo build --release +maturin develop --release cd .. # Run the app @@ -224,7 +225,7 @@ Ecrit/ │ ├── fountain/ # Fountain parser │ ├── project/ # Project file management │ └── lib.rs # PyO3 module -├── tests/ # Test suite (766 tests) +├── tests/ # Test suite (879 tests) ├── openspec/ # Feature tracking and roadmap └── public/ # Static assets ``` diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index b6db8b5..38b4a9a 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -95,10 +95,30 @@ def delete(self, position: int, length: int) -> Operation: def transform(self, op1: Operation, op2: Operation) -> Operation: """Operational transform: adjust op1 against already-applied op2.""" if op2.op_type == OperationType.INSERT: - if op1.position >= op2.position: + insert_len = len(op2.text) + if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length: + # Insert landed inside delete range — expand delete to cover the + # gap the insert created so all originally-targeted chars are removed return Operation( op_type=op1.op_type, - position=op1.position + len(op2.text), + position=op1.position, + text=op1.text, + length=op1.length + insert_len, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) + if op1.position > op2.position or ( + op1.position == op2.position + and ( + op1.op_type == OperationType.DELETE + or (op1.op_type == OperationType.INSERT + and (op1.user_id or "") >= (op2.user_id or "")) + ) + ): + return Operation( + op_type=op1.op_type, + position=op1.position + insert_len, text=op1.text, length=op1.length, user_id=op1.user_id, @@ -106,7 +126,9 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: revision=op1.revision, ) elif op2.op_type == OperationType.DELETE: - if op1.position >= op2.position + op2.length: + op2_end = op2.position + op2.length + if op1.position >= op2_end: + # op1 is entirely after op2's deleted range return Operation( op_type=op1.op_type, position=op1.position - op2.length, @@ -116,7 +138,36 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: timestamp=op1.timestamp, revision=op1.revision, ) + if op1.op_type == OperationType.DELETE: + op1_end = op1.position + op1.length + # Compute overlap between the two delete ranges + overlap_start = max(op1.position, op2.position) + overlap_end = min(op1_end, op2_end) + overlap = max(0, overlap_end - overlap_start) + new_length = op1.length - overlap + if new_length <= 0: + # op1's delete is entirely subsumed by op2 — becomes a no-op + return Operation( + op_type=op1.op_type, + position=op2.position if op1.position >= op2.position else op1.position, + text=op1.text, + length=0, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) + new_pos = min(op1.position, op2.position) + return Operation( + op_type=op1.op_type, + position=new_pos, + text=op1.text, + length=new_length, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) elif op1.position >= op2.position: + # INSERT inside op2's deleted range — move to op2.position return Operation( op_type=op1.op_type, position=op2.position, diff --git a/ecrit/collab/lan.py b/ecrit/collab/lan.py index c320595..b40ef65 100644 --- a/ecrit/collab/lan.py +++ b/ecrit/collab/lan.py @@ -22,6 +22,7 @@ class LANPeer: ip_address: str port: int project_title: str = "" + session_id: str = "" last_seen: float = 0.0 def is_stale(self, timeout: float = 10.0) -> bool: @@ -76,6 +77,9 @@ def get_peers(self) -> list[LANPeer]: self._on_peer_lost(uid) return list(self._peers.values()) + def set_session_id(self, session_id: str) -> None: + self._session_id = session_id + def _make_announce(self) -> bytes: payload = json.dumps({ "service": SERVICE_ID, @@ -83,6 +87,7 @@ def _make_announce(self) -> bytes: "user_name": self.user_name, "port": self.port, "project_title": self._project_title, + "session_id": getattr(self, "_session_id", ""), }) return payload.encode("utf-8") @@ -125,6 +130,7 @@ def _listen_loop(self) -> None: ip_address=addr[0], port=payload.get("port", 8741), project_title=payload.get("project_title", ""), + session_id=payload.get("session_id", ""), last_seen=time.time(), ) with self._peers_lock: diff --git a/ecrit/collab/p2p.py b/ecrit/collab/p2p.py index 2477236..271366c 100644 --- a/ecrit/collab/p2p.py +++ b/ecrit/collab/p2p.py @@ -57,6 +57,7 @@ def __init__(self, user_id: str, user_name: str): self.user_name = user_name self._server_socket: Optional[socket.socket] = None self._clients: dict[str, socket.socket] = {} + self._clients_lock = threading.Lock() self._running = False self._listen_thread: Optional[threading.Thread] = None self._on_message: Optional[Callable[[str, str], None]] = None @@ -85,7 +86,7 @@ def host_session(self, port: int = 0, project_title: str = "") -> ConnectionToke self._server_socket.listen(8) self._server_socket.settimeout(1.0) self._port = self._server_socket.getsockname()[1] - self._session_id = base64.urlsafe_b64encode(os.urandom(6)).decode() + self._session_id = base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("=") self._running = True self._listen_thread = threading.Thread(target=self._accept_loop, daemon=True) @@ -113,7 +114,8 @@ def join_session(self, token: ConnectionToken) -> bool: "session_id": token.session_id, }) self._send_frame(sock, hello) - self._clients["host"] = sock + with self._clients_lock: + self._clients["host"] = sock self._running = True recv_thread = threading.Thread( @@ -125,7 +127,8 @@ def join_session(self, token: ConnectionToken) -> bool: return False def send(self, peer_id: str, message: str) -> bool: - sock = self._clients.get(peer_id) + with self._clients_lock: + sock = self._clients.get(peer_id) if sock: try: self._send_frame(sock, message) @@ -136,8 +139,10 @@ def send(self, peer_id: str, message: str) -> bool: return False def broadcast(self, message: str) -> None: + with self._clients_lock: + snapshot = list(self._clients.items()) dead = [] - for peer_id, sock in self._clients.items(): + for peer_id, sock in snapshot: try: self._send_frame(sock, message) except OSError: @@ -147,12 +152,14 @@ def broadcast(self, message: str) -> None: def close(self) -> None: self._running = False - for sock in self._clients.values(): + with self._clients_lock: + socks = list(self._clients.values()) + self._clients.clear() + for sock in socks: try: sock.close() except OSError: pass - self._clients.clear() if self._server_socket: try: self._server_socket.close() @@ -161,10 +168,16 @@ def close(self) -> None: self._server_socket = None def is_connected(self) -> bool: - return self._running and len(self._clients) > 0 + with self._clients_lock: + return self._running and len(self._clients) > 0 def get_peer_count(self) -> int: - return len(self._clients) + with self._clients_lock: + return len(self._clients) + + def get_client_ids(self) -> list[str]: + with self._clients_lock: + return list(self._clients.keys()) def _send_frame(self, sock: socket.socket, data: str) -> None: encoded = data.encode("utf-8") @@ -201,7 +214,8 @@ def _accept_loop(self) -> None: if hello.get("session_id") != self._session_id: client_sock.close() continue - self._clients[peer_id] = client_sock + with self._clients_lock: + self._clients[peer_id] = client_sock if self._on_connect: self._on_connect(peer_id) recv_thread = threading.Thread( @@ -229,7 +243,8 @@ def _recv_loop(self, peer_id: str, sock: socket.socket) -> None: self._remove_peer(peer_id) def _remove_peer(self, peer_id: str) -> None: - sock = self._clients.pop(peer_id, None) + with self._clients_lock: + sock = self._clients.pop(peer_id, None) if sock: try: sock.close() diff --git a/ecrit/collab/session.py b/ecrit/collab/session.py index 77c15eb..5b3ac0b 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +import threading import uuid from enum import Enum from dataclasses import dataclass, field @@ -12,6 +14,8 @@ from ecrit.collab.lan import LANDiscovery, LANPeer from ecrit.collab.p2p import P2PConnection, ConnectionToken +logger = logging.getLogger("ecrit.collab.session") + class CollabRole(str, Enum): HOST = "host" @@ -53,6 +57,7 @@ def __init__(self, user_name: str = ""): self._p2p = P2PConnection(self.user_id, self.user_name) self._participants: dict[str, Participant] = {} self._pending_ops: list = [] + self._lock = threading.Lock() self._color_index = 0 self._on_state_change: Optional[Callable[[SessionState], None]] = None @@ -102,7 +107,9 @@ def host(self, content: str, project_title: str = "", port: int = 0) -> Connecti ) token = self._p2p.host_session(port=port, project_title=project_title) + self._lan.port = self._p2p._port self._lan.set_project_title(project_title) + self._lan.set_session_id(self._p2p._session_id) self._lan.start() self._set_state(SessionState.CONNECTED) return token @@ -112,7 +119,7 @@ def join_lan(self, peer: LANPeer) -> bool: token = ConnectionToken( host=peer.ip_address, port=peer.port, - session_id="", + session_id=peer.session_id, user_name=peer.user_name, project_title=peer.project_title, ) @@ -159,14 +166,24 @@ def leave(self) -> None: # ── Editing methods ── def apply_local_insert(self, position: int, text: str) -> None: - op = self._crdt.insert(position, text) - self._pending_ops.append(op) + with self._lock: + op = self._crdt.insert(position, text) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + logger.warning("Pending ops exceeded 100; requesting full sync") + self._pending_ops.clear() + self._request_sync() msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) def apply_local_delete(self, position: int, length: int) -> None: - op = self._crdt.delete(position, length) - self._pending_ops.append(op) + with self._lock: + op = self._crdt.delete(position, length) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + logger.warning("Pending ops exceeded 100; requesting full sync") + self._pending_ops.clear() + self._request_sync() msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) @@ -177,6 +194,13 @@ def update_cursor(self, position: int, selection_end: int = -1) -> None: msg = CollabMessage.cursor_update(self.user_id, position, selection_end) self._p2p.broadcast(msg.to_json()) + def _request_sync(self) -> None: + """Ask the host for a full document sync to reset OT state.""" + if self.role == CollabRole.HOST: + return # host is authoritative; nothing to request + sync_msg = CollabMessage.sync_request(self.user_id) + self._p2p.broadcast(sync_msg.to_json()) + def get_text(self) -> str: return self._crdt.get_text() @@ -202,7 +226,8 @@ def get_lan_peers(self) -> list[LANPeer]: def _handle_message(self, peer_id: str, raw: str) -> None: try: msg = CollabMessage.from_json(raw) - except Exception: + except (ValueError, KeyError, AttributeError, TypeError): + logger.debug("Ignoring malformed collab message from %s", peer_id) return if msg.msg_type == MessageType.JOIN: @@ -221,19 +246,20 @@ def _handle_message(self, peer_id: str, raw: str) -> None: elif msg.msg_type == MessageType.OPERATION: op = Operation.from_dict(msg.payload) - transformed_pending = [] - for pending in self._pending_ops: - pending_prime = self._crdt.transform(pending, op) - op = self._crdt.transform(op, pending) - transformed_pending.append(pending_prime) - self._pending_ops = transformed_pending - self._crdt.apply_operation(op) + with self._lock: + transformed_pending = [] + for pending in self._pending_ops: + pending_prime = self._crdt.transform(pending, op) + op = self._crdt.transform(op, pending) + transformed_pending.append(pending_prime) + self._pending_ops = transformed_pending + self._crdt.apply_operation(op) if self._on_text_change: self._on_text_change(self._crdt.get_text()) if self.role == CollabRole.HOST: - relay = CollabMessage.operation(msg.user_id, op.to_dict()) + relay = CollabMessage.operation(msg.user_id, op) relay_data = relay.to_json() - for pid in self._p2p._clients: + for pid in self._p2p.get_client_ids(): if pid != peer_id: self._p2p.send(pid, relay_data) @@ -249,7 +275,7 @@ def _handle_message(self, peer_id: str, raw: str) -> None: msg.payload.get("selection_end", -1), ) if self.role == CollabRole.HOST: - for pid in self._p2p._clients: + for pid in self._p2p.get_client_ids(): if pid != peer_id: self._p2p.send(pid, raw) @@ -262,8 +288,9 @@ def _handle_message(self, peer_id: str, raw: str) -> None: elif msg.msg_type == MessageType.SYNC_RESPONSE: content = msg.payload.get("content", "") - self._crdt.set_text(content) - self._pending_ops.clear() + with self._lock: + self._crdt.set_text(content) + self._pending_ops.clear() if self._on_text_change: self._on_text_change(content) diff --git a/ecrit/companion/device_sync.py b/ecrit/companion/device_sync.py index 79b9cae..7ea0cce 100644 --- a/ecrit/companion/device_sync.py +++ b/ecrit/companion/device_sync.py @@ -154,9 +154,11 @@ def start_transfer_server(self, bundle_path: str, port: int = 8739) -> str: self.stop_transfer_server() token = secrets.token_urlsafe(32) - _TransferHandler.bundle_path = bundle_path - _TransferHandler.transfer_token = token - self._server = HTTPServer(("0.0.0.0", port), _TransferHandler) + handler = type("_Handler", (_TransferHandler,), { + "bundle_path": bundle_path, + "transfer_token": token, + }) + self._server = HTTPServer(("0.0.0.0", port), handler) self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._server_thread.start() self._set_status(SyncStatus.TRANSFERRING) diff --git a/ecrit/export/epub_export.py b/ecrit/export/epub_export.py index bb99195..6486ed7 100644 --- a/ecrit/export/epub_export.py +++ b/ecrit/export/epub_export.py @@ -1,5 +1,6 @@ """EPUB 3 export — generate a valid EPUB ebook from Fountain screenplay content.""" +import logging import os import re import uuid @@ -245,6 +246,34 @@ def _generate_css() -> str: border: none; page-break-after: always; } +h2.page-header { + font-family: "Courier New", Courier, monospace; + font-size: 12pt; + font-weight: bold; + text-transform: uppercase; + margin-top: 1.5em; + margin-bottom: 0.5em; + page-break-before: always; +} +h2.page-header:first-of-type { + page-break-before: avoid; +} +p.panel-header { + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.3em; + margin-left: 1em; +} +p.sfx { + font-weight: bold; + text-transform: uppercase; + margin: 0.3em 0; + margin-left: 1em; +} +p.caption { + margin: 0.3em 0; + margin-left: 1em; +} """ @@ -350,28 +379,38 @@ def _generate_title_page(title: str, author: str, extra_fields: dict[str, str] | "Section": "section", "Synopsis": "synopsis", "Note": "note", + "PageHeader": "page-header", + "PanelHeader": "panel-header", + "Sfx": "sfx", + "Caption": "caption", } -def _parse_fountain_to_xhtml(content: str) -> tuple[str, list[dict]]: +def _parse_fountain_to_xhtml(content: str, format_id: str = "fountain/core") -> tuple[str, list[dict]]: """Convert Fountain text to XHTML body content. Returns a tuple of (xhtml_body_content, scenes) where scenes is a list of {"id": str, "text": str} dicts for the table of contents. """ - elements = _parse_fountain_to_elements(content) + try: + import json + import ecrit_core + parsed = json.loads(ecrit_core.parse_fountain(content, format_id)) + elements = parsed.get("elements", []) + except Exception: + elements = _parse_fountain_to_elements(content) parts: list[str] = [] scenes: list[dict] = [] scene_count = 0 for elem in elements: - kind = elem["type"] - text = escape(elem["text"]) - - if kind == "PageBreak": - parts.append('
{text}
') + continue + + if kind == "Sfx": + num = elem.get("number", "") + display = f"{num}. SFX: {text}" if num else f"SFX: {text}" + parts.append(f'{display}
') + continue + + if kind == "Caption": + num = elem.get("number", "") + subtype = elem.get("subtype", "CAPTION") + display = f"{num}. {subtype}: {text}" if num else f"{subtype}: {text}" + parts.append(f'{display}
') + continue + css_class = _ELEMENT_CLASS.get(kind, "action") parts.append(f'{text}
') @@ -402,7 +465,7 @@ def _generate_script_xhtml(title: str, body_content: str) -> str: f'