#!/usr/bin/env python3
"""obsidian-web — 文档系统
/doc/<id> 从 OSS 读取文档内容
/api/bus/* 公交数据库 API
"""
import os, sys, re, json, urllib.parse, sqlite3, base64
from flask import Flask, request, redirect, render_template_string, make_response
SECRET_KEY = "obsidian-web-secret-key-change-me"
app = Flask(__name__)
app.secret_key = SECRET_KEY
# ── Markdown 渲染 ────────────────────────
def display_name(path):
n = os.path.splitext(os.path.basename(path))[0]
return n.replace("-", " ").replace("_", " ")
def wikilink_to_html(text):
def repl(m):
inner = m.group(1)
if "|" in inner:
_, display = inner.split("|", 1)
else:
display = inner
return '<span class="wikilink-broken">' + display + '</span>'
return re.sub(r'\[\[([^\]]+)\]\]', repl, text)
def render_markdown(content):
import markdown as md_lib
content = wikilink_to_html(content)
# DrawIO 代码块预渲染:```drawio ... ``` → <div class="drawio-container">
_drawio_idx = [0] # mutable counter for block index tracking
def drawio_repl(m):
xml_raw = m.group(1)
_drawio_idx[0] += 1
idx = _drawio_idx[0]
import uuid
uid = uuid.uuid4().hex[:8]
xml_b64 = base64.b64encode(xml_raw.encode('utf-8')).decode('ascii')
embed_url = f"https://viewer.diagrams.net/?embed=1&ui=dark&spin=1&proto=json&data=data:image/svg+xml;base64,{xml_b64}"
return f'''<div class="drawio-container" id="drawio-{uid}" data-block-idx="{idx}">
<div class="drawio-toolbar">
<span class="drawio-label">📐 架构图 #{idx}</span>
<button class="drawio-edit-btn" onclick="editDrawio('{uid}')">✏️ 编辑</button>
</div>
<iframe src="{embed_url}" frameborder="0" class="drawio-iframe" id="iframe-{uid}" loading="lazy"></iframe>
</div>'''
content = re.sub(r'```drawio\n(.*?)```', drawio_repl, content, flags=re.DOTALL)
html = md_lib.markdown(content, extensions=[
"extra", "fenced_code", "tables", "toc", "codehilite", "sane_lists"
])
return html
# ── 模板 ──────────────────────────────────
TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — {{ repo_name }}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;background:#f0f0f0;color:#1a1a2e;}
#content{max-width:960px;margin:0 auto;padding:24px 20px;background:#fff;min-height:100vh;box-shadow:0 0 30px rgba(0,0,0,.06);}
#content h1{font-size:28px;margin-bottom:20px;color:#1e1e2e;border-bottom:2px solid #cba6f7;padding-bottom:12px;font-weight:700;}
#content h2{font-size:22px;margin:32px 0 12px;color:#313244;font-weight:600;padding-bottom:6px;border-bottom:1px solid #eee;}
#content h3{font-size:18px;margin:24px 0 10px;color:#45475a;font-weight:600;}
#content h4{font-size:16px;margin:20px 0 8px;color:#585b70;font-weight:600;}
#content p{line-height:1.9;margin:12px 0;color:#333;font-size:15px;}
#content ul,#content ol{margin:12px 0 12px 24px;line-height:1.9;font-size:15px;}
#content li{margin:4px 0;}
#content li>p{margin:4px 0;}
#content table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.05);}
#content th,#content td{border:1px solid #e0e0e0;padding:10px 14px;text-align:left;}
#content th{background:#f5f3ff;font-weight:600;color:#45475a;}
#content tr:nth-child(even){background:#fafafa;}
#content tr:hover{background:#f0edff;}
#content code{background:#f0edff;padding:2px 6px;border-radius:4px;font-size:13px;color:#7c3aed;font-family:"JetBrains Mono","Fira Code","Consolas",monospace;}
#content pre{background:#1e1e2e;color:#cdd6f4;padding:18px 20px;border-radius:10px;overflow-x:auto;margin:16px 0;font-size:13.5px;line-height:1.6;box-shadow:0 2px 8px rgba(0,0,0,.1);}
#content pre code{background:none;padding:0;color:inherit;font-size:inherit;}
#content .hljs-keyword{color:#cba6f7;}
#content .hljs-string{color:#a6e3a1;}
#content .hljs-comment{color:#6c7086;font-style:italic;}
#content .hljs-function{color:#89b4fa;}
#content .hljs-number{color:#fab387;}
#content .hljs-built_in{color:#f38ba8;}
#content blockquote{border-left:4px solid #cba6f7;padding:12px 20px;margin:16px 0;background:#f9f7ff;color:#555;border-radius:0 6px 6px 0;}
#content blockquote p{margin:4px 0;color:#666;}
#content a{color:#7c3aed;text-decoration:none;font-weight:500;}
#content a:hover{text-decoration:underline;color:#6d28d9;}
#content img{max-width:100%;border-radius:8px;margin:16px 0;box-shadow:0 2px 12px rgba(0,0,0,.08);}
#content hr{border:none;border-top:1px solid #e0e0e0;margin:32px 0;}
.wikilink-broken{color:#999;cursor:help;border-bottom:1px dashed #ccc;font-weight:normal;}
.file-path{font-size:12px;color:#999;margin-bottom:16px;padding:6px 12px;background:#f9f7ff;border-radius:6px;display:inline-block;}
.drawio-container{border:1px solid #e0e0e0;border-radius:10px;overflow:hidden;margin:20px 0;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.06);}
.drawio-toolbar{display:flex;align-items:center;justify-content:space-between;padding:8px 16px;background:#f5f3ff;border-bottom:1px solid #e0e0e0;}
.drawio-label{font-size:13px;color:#7c3aed;font-weight:600;}
.drawio-edit-btn{font-size:12px;padding:4px 12px;border:1px solid #cba6f7;border-radius:6px;background:#fff;color:#7c3aed;cursor:pointer;transition:all .15s;}
.drawio-edit-btn:hover{background:#7c3aed;color:#fff;}
.drawio-iframe{width:100%;height:500px;border:none;}
.drawio-edit-modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;}
.drawio-edit-modal iframe{width:95%;height:95%;border:none;border-radius:8px;box-shadow:0 4px 30px rgba(0,0,0,.3);}
.drawio-edit-close{position:fixed;top:16px;right:16px;font-size:24px;color:#fff;cursor:pointer;z-index:10000;background:rgba(0,0,0,.4);width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;border:1px solid rgba(255,255,255,.2);}
</style>
</head>
<body>
<div id="content">{{ content|safe }}</div>
<script>
// ── DrawIO 编辑器 ──
var activeDrawioId = null;
function editDrawio(uid) {
activeDrawioId = uid;
var iframe = document.getElementById('iframe-' + uid);
var viewUrl = iframe.src;
var editorUrl = viewUrl.replace('viewer.diagrams.net', 'embed.diagrams.net');
var modal = document.createElement('div');
modal.className = 'drawio-edit-modal';
modal.id = 'drawio-modal';
modal.innerHTML = '<span class="drawio-edit-close" onclick="closeDrawioEditor()">✕</span>' +
'<iframe src="' + editorUrl + '" id="editor-' + uid + '"></iframe>';
document.body.appendChild(modal);
}
function closeDrawioEditor() {
var modal = document.getElementById('drawio-modal');
if (modal) modal.remove();
activeDrawioId = null;
}
// Listen for drawio save event (postMessage from embed.diagrams.net)
window.addEventListener('message', function(e) {
if (!e.data || typeof e.data !== 'object') return;
if (e.data.event === 'save') {
var xml = e.data.data;
if (!xml || !activeDrawioId) return;
var uid = activeDrawioId;
var container = document.getElementById('drawio-' + uid);
if (!container) return;
var blockIdx = container.getAttribute('data-block-idx');
if (!blockIdx) {
alert('无法定位图表块');
return;
}
// Send to server to save back to the document
var docId = document.getElementById('content').getAttribute('data-doc-id');
if (!docId) {
alert('无法获取文档ID,请手动复制XML保存');
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/doc/' + docId + '/drawio-save', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() {
if (xhr.status === 200) {
alert('✅ 图表已保存!页面将重新加载。');
closeDrawioEditor();
setTimeout(function() { location.reload(); }, 500);
} else {
alert('❌ 保存失败: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('❌ 网络错误,保存失败');
};
xhr.send(JSON.stringify({xml: xml, block_index: parseInt(blockIdx)}));
}
});
// Set doc-id on content div for drawio save to reference
document.addEventListener('DOMContentLoaded', function() {
var pathParts = window.location.pathname.split('/');
var docId = pathParts[pathParts.length - 1];
if (docId && /^\d+$/.test(docId)) {
document.getElementById('content').setAttribute('data-doc-id', docId);
}
});
</script>
</body>
</html>"""
# ── 路由:文档 /doc/<id> ────────────
DOC_DB = "/root/content.db"
@app.route("/doc/<doc_id>")
def view_doc(doc_id):
sqlite_db = sqlite3.connect(DOC_DB)
sqlite_db.row_factory = sqlite3.Row
try:
cur = sqlite_db.execute("SELECT id, title, type FROM documents WHERE id=?", (doc_id,))
doc = cur.fetchone()
if not doc:
return "<h1>404</h1><p>文档未找到</p>", 404
doc_type = doc["type"]
doc_title = doc["title"]
# 从 OSS 读取内容(先试doc_id,不追加.md再试)
import subprocess
oss_key = f"oss://rosicky-doc/contents/{doc_id}"
r = subprocess.run(["ossutil", "cat", oss_key], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if r.returncode != 0:
# Fallback: 试带 .md 后缀(修复早期上传时带扩展名导致500的兼容问题)
oss_key_md = f"oss://rosicky-doc/contents/{doc_id}.md"
r = subprocess.run(["ossutil", "cat", oss_key_md], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if r.returncode != 0:
return "<h1>500</h1><p>读取文档失败</p>", 500
content = r.stdout.decode("utf-8")
if doc_type == 2: # HTML
if "<!DOCTYPE" in content[:200] or "<html" in content[:200]:
resp = make_response(content)
resp.headers['Content-Type'] = 'text/html; charset=utf-8'
return resp
body_html = content
else: # MD
body_html = render_markdown(content)
header = f'<div style="font-size:12px;color:#999;margin-bottom:16px;">📄 {doc_title}</div>'
body = header + body_html
return render_template_string(TEMPLATE, title=doc_title, repo_name="📖 文档系统",
content=body, nav="", count=0, show_sidebar=False)
finally:
sqlite_db.close()
@app.route("/doc/")
def doc_list():
sqlite_db = sqlite3.connect(DOC_DB)
sqlite_db.row_factory = sqlite3.Row
try:
rows = sqlite_db.execute("SELECT id, title, folder_id FROM documents ORDER BY title").fetchall()
items = "".join(f'<li><a href="/doc/{r["id"]}">{r["title"]}</a></li>' for r in rows)
body = f"<h1>📖 文档列表</h1><ul>{items}</ul>"
return render_template_string(TEMPLATE, title="文档列表", repo_name="📖 文档系统",
content=body, nav="", count=0, show_sidebar=False)
finally:
sqlite_db.close()
# ── API: DrawIO 保存 ──────────────────────
@app.route("/api/doc/<doc_id>/drawio-save", methods=["POST"])
def drawio_save(doc_id):
"""接收编辑后的 drawio XML,替换文档中对应的 ```drawio 代码块"""
data = request.get_json(force=True)
new_xml = data.get("xml", "")
block_idx = data.get("block_index")
if not new_xml or block_idx is None:
return json.dumps({"error": "missing xml or block_index"}), 400
# 从 OSS 读取当前内容(兼容带.md后缀的文件)
oss_key = f"oss://rosicky-doc/contents/{doc_id}"
import subprocess
r = subprocess.run(["ossutil", "cat", oss_key], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if r.returncode != 0:
oss_key_md = f"oss://rosicky-doc/contents/{doc_id}.md"
r = subprocess.run(["ossutil", "cat", oss_key_md], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if r.returncode != 0:
return json.dumps({"error": "读取文档失败"}), 500
content = r.stdout.decode("utf-8")
# 查找所有 ```drawio 代码块
pattern = r'```drawio\n(.*?)```'
blocks = list(re.finditer(pattern, content, re.DOTALL))
if block_idx < 1 or block_idx > len(blocks):
return json.dumps({"error": f"block index {block_idx} out of range (1-{len(blocks)})"}), 400
m = blocks[block_idx - 1]
old_full = m.group(0)
new_full = f"```drawio\n{new_xml}```"
# 替换(只替换第一个匹配,block 索引唯一)
updated_content = content.replace(old_full, new_full, 1)
if updated_content == content:
return json.dumps({"error": "替换失败,内容未变化"}), 500
# 写回 OSS
import oss2, configparser
cfg = configparser.ConfigParser()
cfg.read(os.path.expanduser("~/.ossutilconfig"))
AK_ID = cfg["Credentials"]["accessKeyID"]
AK_SECRET = cfg["Credentials"]["accessKeySecret"]
ENDPOINT = cfg["Credentials"]["endpoint"]
auth = oss2.Auth(AK_ID, AK_SECRET)
bucket = oss2.Bucket(auth, ENDPOINT, "rosicky-doc")
bucket.put_object(f"contents/{doc_id}", updated_content.encode("utf-8"))
return json.dumps({"success": True, "message": "图表已保存"})
# ── 公交数据库 API ─────────────────────────┤
BUS_DB_PATH = "/root/bus_database.db"
def get_db():
conn = sqlite3.connect(BUS_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@app.route("/api/bus/models")
def api_bus_models():
conn = get_db()
rows = conn.execute("""SELECT m.*, b.brand_name, b.full_name
FROM models m LEFT JOIN brands b ON m.brand_id = b.brand_id
ORDER BY m.model_id""").fetchall()
conn.close()
return json.dumps([dict(r) for r in rows], ensure_ascii=False, default=str)
@app.route("/api/bus/models/<model_id>")
def api_bus_model(model_id):
conn = get_db()
row = conn.execute("""SELECT m.*, b.brand_name, b.full_name
FROM models m LEFT JOIN brands b ON m.brand_id = b.brand_id
WHERE m.model_id=?""", (model_id,)).fetchone()
if not row:
return json.dumps({"error": "not found"}), 404
result = dict(row)
images = conn.execute("SELECT * FROM images WHERE model_id=? ORDER BY is_primary DESC, quality_score DESC", (model_id,)).fetchall()
result["images"] = [dict(i) for i in images]
routes = conn.execute("""SELECT r.* FROM routes r
JOIN model_routes mr ON r.route_id = mr.route_id
WHERE mr.model_id=?""", (model_id,)).fetchall()
result["routes"] = [dict(r) for r in routes]
conn.close()
return json.dumps(result, ensure_ascii=False, default=str)
@app.route("/api/bus/stats")
def api_bus_stats():
conn = get_db()
stats = {}
stats["total_models"] = conn.execute("SELECT COUNT(*) FROM models").fetchone()[0]
stats["total_brands"] = conn.execute("SELECT COUNT(*) FROM brands").fetchone()[0]
stats["total_images"] = conn.execute("SELECT COUNT(*) FROM images").fetchone()[0]
stats["total_pending"] = conn.execute("SELECT COUNT(*) FROM pending_images").fetchone()[0]
stats["total_routes"] = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0]
conn.close()
return json.dumps(stats, default=str)
@app.route("/api/bus/routes")
def api_bus_routes():
conn = get_db()
rows = conn.execute("""SELECT r.*,
(SELECT GROUP_CONCAT(mr2.model_id) FROM model_routes mr2 WHERE mr2.route_id=r.route_id) as models
FROM routes r ORDER BY r.route_name""").fetchall()
conn.close()
return json.dumps([dict(r) for r in rows], ensure_ascii=False, default=str)
@app.route("/api/bus/sources")
def api_bus_sources():
conn = get_db()
rows = conn.execute("""SELECT s.*,
(SELECT COUNT(*) FROM pending_images p WHERE p.source_id=s.source_id) as pending_count
FROM sources s ORDER BY s.name""").fetchall()
conn.close()
return json.dumps([dict(r) for r in rows], ensure_ascii=False, default=str)
@app.route("/api/bus/search")
def api_bus_search():
q = request.args.get("q", "").strip()
if not q:
return json.dumps({"models": [], "brands": [], "routes": []})
conn = get_db()
models = conn.execute("""SELECT m.*, b.brand_name, b.full_name
FROM models m LEFT JOIN brands b ON m.brand_id = b.brand_id
WHERE m.model_name LIKE ? OR b.brand_name LIKE ? OR m.manufacturer LIKE ?
ORDER BY m.model_id LIMIT 20""", (f"%{q}%", f"%{q}%", f"%{q}%")).fetchall()
brands = conn.execute("SELECT * FROM brands WHERE brand_name LIKE ? ORDER BY brand_name LIMIT 10", (f"%{q}%",)).fetchall()
routes = conn.execute("SELECT * FROM routes WHERE route_name LIKE ? LIMIT 20", (f"%{q}%",)).fetchall()
conn.close()
return json.dumps({
"models": [dict(m) for m in models],
"brands": [dict(b) for b in brands],
"routes": [dict(r) for r in routes],
}, ensure_ascii=False, default=str)
@app.route("/api/bus/confirm", methods=["POST", "OPTIONS"])
def api_bus_confirm():
if request.method == "OPTIONS":
return "", 200
data = request.json or {}
action = data.get("action", "")
model_id = data.get("model_id")
source_id = data.get("source_id")
if not model_id:
return json.dumps({"error": "model_id required"}), 400
conn = get_db()
try:
if action == "approve":
pending = conn.execute("SELECT * FROM pending_images WHERE model_id=? ORDER BY RANDOM() LIMIT 1", (model_id,)).fetchone()
if not pending:
return json.dumps({"error": "no pending"}), 404
image_url = pending["image_url"]
quality = data.get("quality", 0.8)
source = data.get("source_name", pending["source_name"])
conn.execute("""INSERT INTO images (model_id, image_url, is_primary, source_name, quality_score, view_count)
VALUES (?, ?, 0, ?, ?, 0)""", (model_id, image_url, source, quality))
conn.execute("DELETE FROM pending_images WHERE id=?", (pending["id"],))
conn.execute("""INSERT INTO adoption_logs (model_id, method, result, reviewed_by, source_name)
VALUES (?, '人工确认', 'approved', '亮弟', ?)""", (model_id, source))
conn.commit()
return json.dumps({"status": "ok", "message": "已采纳"})
elif action == "reject":
pending = conn.execute("SELECT * FROM pending_images WHERE model_id=? ORDER BY RANDOM() LIMIT 1", (model_id,)).fetchone()
if not pending:
return json.dumps({"error": "no pending"}), 404
source = data.get("source_name", pending["source_name"])
conn.execute("DELETE FROM pending_images WHERE id=?", (pending["id"],))
conn.execute("""INSERT INTO adoption_logs (model_id, method, result, reviewed_by, source_name)
VALUES (?, '人工确认', 'rejected', '亮弟', ?)""", (model_id, source))
conn.commit()
return json.dumps({"status": "ok", "message": "已拒绝"})
elif action == "add_route":
route_name = data.get("route_name", "").strip()
if not route_name:
return json.dumps({"error": "route_name required"}), 400
row = conn.execute("SELECT route_id FROM routes WHERE route_name=?", (route_name,)).fetchone()
if row:
route_id = row["route_id"]
else:
cursor = conn.execute("INSERT INTO routes (route_name) VALUES (?)", (route_name,))
route_id = cursor.lastrowid
conn.execute("INSERT OR IGNORE INTO model_routes (model_id, route_id) VALUES (?, ?)", (model_id, route_id))
conn.execute("""INSERT INTO adoption_logs (model_id, method, result, reviewed_by, source_name)
VALUES (?, '人工确认', 'route_added', '亮弟', ?)""", (model_id, f"route:{route_name}"))
conn.commit()
return json.dumps({"status": "ok", "message": f"路线 {route_name} 已关联"})
else:
return json.dumps({"error": "unknown action"}), 400
finally:
conn.close()
# ── 健康检查 ──────────────────────────────
@app.route("/health")
def health():
return "OK", 200
# ── 启动 ──────────────────────────────────
if __name__ == "__main__":
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
print(f"📖 文档系统 v3")
print(f" 端口: {PORT}")
print(f" /doc/<id> 从 OSS 读取文档")
print(f" /api/bus/* 公交数据库 API")
print(f" /health 健康检查")
app.run(host="0.0.0.0", port=PORT, debug=False)