282 lines
6.1 KiB
Python
282 lines
6.1 KiB
Python
"""
|
|
Flask collaboration server example for ribbit.
|
|
|
|
Demonstrates: WebSocket relay, presence, revisions, and locking.
|
|
Requires: flask, flask-sock
|
|
|
|
pip install flask flask-sock
|
|
python server.py
|
|
|
|
Then open http://localhost:5000 in multiple browser tabs.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
|
|
from flask import Flask, jsonify, render_template, request
|
|
from flask_sock import Sock
|
|
|
|
app = Flask(__name__)
|
|
sock = Sock(app)
|
|
|
|
# In-memory state (replace with a database in production)
|
|
document = {"content": """# Ribbit Demo Document
|
|
|
|
## Inline Formatting
|
|
|
|
@block(examples
|
|
|
|
@block(example
|
|
### Type this
|
|
`**bold**`
|
|
### To get this
|
|
**bold**
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
`*italic*`
|
|
### To get this
|
|
*italic*
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
`***bold italic***`
|
|
### To get this
|
|
***bold italic***
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
`~~strikethrough~~`
|
|
### To get this
|
|
~~strikethrough~~
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
`` `inline code` ``
|
|
### To get this
|
|
`inline code`
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
`[link](http://example.com)`
|
|
### To get this
|
|
[link](http://example.com)
|
|
)
|
|
|
|
)
|
|
|
|
## Block Elements
|
|
|
|
@block(examples
|
|
|
|
@block(example
|
|
### Type this
|
|
```
|
|
- apples
|
|
- bananas
|
|
- cherries
|
|
```
|
|
### To get this
|
|
- apples
|
|
- bananas
|
|
- cherries
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
```
|
|
1. Step one
|
|
2. Step two
|
|
3. Step three
|
|
```
|
|
### To get this
|
|
1. Step one
|
|
2. Step two
|
|
3. Step three
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
```
|
|
> First line
|
|
> Second line
|
|
> Third line
|
|
```
|
|
### To get this
|
|
> First line
|
|
> Second line
|
|
> Third line
|
|
)
|
|
|
|
@block(example
|
|
### Type this
|
|
````
|
|
```python
|
|
def hello():
|
|
print("Hello!")
|
|
```
|
|
````
|
|
### To get this
|
|
```python
|
|
def hello():
|
|
print("Hello!")
|
|
```
|
|
)
|
|
|
|
)
|
|
|
|
## Full Example
|
|
|
|
Here is a paragraph with **bold**, *italic*, and `code` inline.
|
|
A [link](http://example.com) and ~~deleted text~~ too.
|
|
|
|
> A blockquote with **formatting** inside.
|
|
|
|
- List with *italic*
|
|
- And `code`
|
|
|
|
***
|
|
"""}
|
|
revisions = []
|
|
lock_holder = None
|
|
lock_mutex = Lock()
|
|
clients = {} # ws -> user info
|
|
|
|
|
|
# ── Pages ────────────────────────────────────────────────
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template("index.html", content=document["content"])
|
|
|
|
|
|
# ── Revisions API ────────────────────────────────────────
|
|
|
|
@app.route("/api/revisions", methods=["GET"])
|
|
def list_revisions():
|
|
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
|
|
|
|
|
|
@app.route("/api/revisions/<revision_id>", methods=["GET"])
|
|
def get_revision(revision_id):
|
|
for r in revisions:
|
|
if r["id"] == revision_id:
|
|
return jsonify(r)
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
|
|
@app.route("/api/revisions", methods=["POST"])
|
|
def create_revision():
|
|
data = request.json
|
|
rev = {
|
|
"id": str(uuid.uuid4())[:8],
|
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"author": data.get("author", "anonymous"),
|
|
"summary": data.get("summary", ""),
|
|
"content": data.get("content", document["content"]),
|
|
}
|
|
revisions.append(rev)
|
|
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
|
|
return jsonify(rev), 201
|
|
|
|
|
|
# ── Locking API ──────────────────────────────────────────
|
|
|
|
@app.route("/api/lock", methods=["POST"])
|
|
def acquire_lock():
|
|
global lock_holder
|
|
with lock_mutex:
|
|
if lock_holder is None:
|
|
lock_holder = request.json
|
|
broadcast_json({"type": "lock", "holder": lock_holder})
|
|
return jsonify({"ok": True})
|
|
return jsonify({"ok": False, "holder": lock_holder}), 409
|
|
|
|
|
|
@app.route("/api/lock", methods=["DELETE"])
|
|
def release_lock():
|
|
global lock_holder
|
|
with lock_mutex:
|
|
lock_holder = None
|
|
broadcast_json({"type": "lock", "holder": None})
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/lock/force", methods=["POST"])
|
|
def force_lock():
|
|
global lock_holder
|
|
with lock_mutex:
|
|
lock_holder = request.json
|
|
broadcast_json({"type": "lock", "holder": lock_holder})
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ── WebSocket relay ──────────────────────────────────────
|
|
|
|
@sock.route("/ws")
|
|
def websocket(ws):
|
|
client_id = str(uuid.uuid4())[:8]
|
|
clients[client_id] = {"ws": ws, "user": None}
|
|
|
|
try:
|
|
while True:
|
|
data = ws.receive()
|
|
|
|
if isinstance(data, bytes):
|
|
# Binary = document update, relay to all other clients
|
|
document["content"] = data.decode("utf-8")
|
|
for cid, client in clients.items():
|
|
if cid != client_id:
|
|
try:
|
|
client["ws"].send(data)
|
|
except Exception:
|
|
pass
|
|
|
|
elif isinstance(data, str):
|
|
msg = json.loads(data)
|
|
|
|
if msg.get("type") == "join":
|
|
clients[client_id]["user"] = msg.get("user")
|
|
# Send current document state
|
|
ws.send(document["content"].encode("utf-8"))
|
|
# Send current lock state
|
|
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
|
|
# Broadcast updated peer list
|
|
broadcast_peers()
|
|
|
|
elif msg.get("type") == "presence":
|
|
clients[client_id]["user"] = msg
|
|
broadcast_peers()
|
|
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
del clients[client_id]
|
|
broadcast_peers()
|
|
|
|
|
|
def broadcast_json(msg):
|
|
data = json.dumps(msg)
|
|
for client in clients.values():
|
|
try:
|
|
client["ws"].send(data)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def broadcast_peers():
|
|
peers = [c["user"] for c in clients.values() if c["user"]]
|
|
broadcast_json({"type": "peers", "peers": peers})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(debug=True, host="0.0.0.0", port=5000)
|