Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
4854d40508 | ||
|
|
7dd3133475 | ||
|
|
24dfa39f91 | ||
|
|
7ec65f7c12 | ||
|
|
4f8edef42b | ||
|
|
7818410d55 | ||
|
|
1762191b03 | ||
|
|
2df92e809e | ||
|
|
59a8d32e40 | ||
|
|
c3b3963760 | ||
|
|
a0cd39e3bc | ||
|
|
e48b2258e0 | ||
|
|
35aaf8824f | ||
|
|
76c69ca3ec | ||
|
|
9994225911 | ||
|
|
03f699e448 | ||
|
|
2fd01a5ab7 | ||
|
|
96629113f1 | ||
|
|
973afa0cc2 | ||
|
|
9bc55e771a | ||
|
|
40a292d05e | ||
|
|
bf723db2cc | ||
|
|
46052172b2 | ||
|
|
4f777f0003 | ||
|
|
b5e1ba72d0 | ||
|
|
df120098f9 | ||
|
|
1c1d578db7 | ||
|
|
69352fe0b5 | ||
|
|
fb3980dad0 | ||
|
|
6c21a1e619 | ||
|
|
112c0dc70a | ||
|
|
538a9ce25d |
-2558
File diff suppressed because one or more lines are too long
@@ -1,289 +0,0 @@
|
|||||||
import http.server
|
|
||||||
import socketserver
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
import mimetypes
|
|
||||||
import html
|
|
||||||
|
|
||||||
# --- Theme selection (choose one: "light1", "light2", "dark1", "dark2") ---
|
|
||||||
THEME = "light1" # Change this to "light2", "dark1", or "dark2" as desired
|
|
||||||
|
|
||||||
THEMES = {
|
|
||||||
"light1": """
|
|
||||||
body { font-family: Arial, sans-serif; background: #f6f8fa; margin: 0; padding: 0; color: #222; }
|
|
||||||
.container { max-width: 960px; margin: auto; padding: 2em; }
|
|
||||||
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #ccc; }
|
|
||||||
.commit { margin: 1em 0; padding: 1em; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.07); }
|
|
||||||
.hash { color: #555; font-family: monospace; }
|
|
||||||
.diff { white-space: pre-wrap; background: #f1f1f1; padding: 1em; border-radius: 4px; overflow-x: auto; }
|
|
||||||
ul { list-style: none; padding-left: 0; }
|
|
||||||
li { margin: 0.3em 0; }
|
|
||||||
a { text-decoration: none; color: #0366d6; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
""",
|
|
||||||
"light2": """
|
|
||||||
body { font-family: 'Segoe UI', sans-serif; background: #fdf6e3; margin: 0; padding: 0; color: #333; }
|
|
||||||
.container { max-width: 900px; margin: auto; padding: 2em; }
|
|
||||||
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #e1c699; color: #b58900; }
|
|
||||||
.commit { margin: 1em 0; padding: 1em; background: #fffbe6; border-radius: 6px; box-shadow: 0 1px 3px rgba(200,180,100,0.08); }
|
|
||||||
.hash { color: #b58900; font-family: monospace; }
|
|
||||||
.diff { white-space: pre-wrap; background: #f5e9c9; padding: 1em; border-radius: 4px; overflow-x: auto; }
|
|
||||||
ul { list-style: none; padding-left: 0; }
|
|
||||||
li { margin: 0.3em 0; }
|
|
||||||
a { text-decoration: none; color: #b58900; }
|
|
||||||
a:hover { text-decoration: underline; color: #cb4b16; }
|
|
||||||
""",
|
|
||||||
"dark1": """
|
|
||||||
body { font-family: Arial, sans-serif; background: #181a1b; margin: 0; padding: 0; color: #eaeaea; }
|
|
||||||
.container { max-width: 960px; margin: auto; padding: 2em; }
|
|
||||||
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #333; color: #8ab4f8; }
|
|
||||||
.commit { margin: 1em 0; padding: 1em; background: #23272b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.18); }
|
|
||||||
.hash { color: #8ab4f8; font-family: monospace; }
|
|
||||||
.diff { white-space: pre-wrap; background: #23272b; padding: 1em; border-radius: 4px; overflow-x: auto; }
|
|
||||||
ul { list-style: none; padding-left: 0; }
|
|
||||||
li { margin: 0.3em 0; }
|
|
||||||
a { text-decoration: none; color: #8ab4f8; }
|
|
||||||
a:hover { text-decoration: underline; color: #bb86fc; }
|
|
||||||
""",
|
|
||||||
"dark2": """
|
|
||||||
body { font-family: 'Fira Sans', sans-serif; background: #121212; margin: 0; padding: 0; color: #d0d0d0; }
|
|
||||||
.container { max-width: 900px; margin: auto; padding: 2em; }
|
|
||||||
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #444; color: #ffb86c; }
|
|
||||||
.commit { margin: 1em 0; padding: 1em; background: #22223b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.22); }
|
|
||||||
.hash { color: #ffb86c; font-family: monospace; }
|
|
||||||
.diff { white-space: pre-wrap; background: #282a36; padding: 1em; border-radius: 4px; overflow-x: auto; }
|
|
||||||
ul { list-style: none; padding-left: 0; }
|
|
||||||
li { margin: 0.3em 0; }
|
|
||||||
a { text-decoration: none; color: #ffb86c; }
|
|
||||||
a:hover { text-decoration: underline; color: #8be9fd; }
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
def HTML_TEMPLATE(content, theme=THEME):
|
|
||||||
return f"""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang=\"en\">
|
|
||||||
<head>
|
|
||||||
<meta charset=\"UTF-8\">
|
|
||||||
<title>Git Log Viewer</title>
|
|
||||||
<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\">
|
|
||||||
<style>
|
|
||||||
{THEMES[theme]}
|
|
||||||
</style>
|
|
||||||
<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>
|
|
||||||
<script>hljs.highlightAll();</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class=\"container\">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
REPO_ROOT = os.path.abspath(".")
|
|
||||||
LOG_FILE = os.path.join(REPO_ROOT, "gitlog.jsonl")
|
|
||||||
|
|
||||||
PORT = 8481
|
|
||||||
|
|
||||||
def format_diff_to_html(diff_text: str) -> str:
|
|
||||||
lines = diff_text.strip().splitlines()
|
|
||||||
html_lines = ['<div style="font-family: monospace; white-space: pre;">']
|
|
||||||
while not lines[3].startswith('diff'):
|
|
||||||
lines.pop(3)
|
|
||||||
lines.insert(3, "")
|
|
||||||
for line in lines:
|
|
||||||
escaped = html.escape(line)
|
|
||||||
if "//" in line:
|
|
||||||
continue
|
|
||||||
if "#" in line:
|
|
||||||
continue
|
|
||||||
if "/*" in line:
|
|
||||||
continue
|
|
||||||
if "*/" in line:
|
|
||||||
continue
|
|
||||||
if line.startswith('+++') or line.startswith('---'):
|
|
||||||
html_lines.append(f'<div style="color: #0000aa;">{escaped}</div>')
|
|
||||||
elif line.startswith('@@'):
|
|
||||||
html_lines.append(f'<div style="color: #005cc5;">{escaped}</div>')
|
|
||||||
elif line.startswith('+'):
|
|
||||||
html_lines.append(f'<div style="color: #22863a;">{escaped}</div>')
|
|
||||||
elif line.startswith('-'):
|
|
||||||
html_lines.append(f'<div style="color: #b31d28;">{escaped}</div>')
|
|
||||||
elif line.startswith('\\'):
|
|
||||||
html_lines.append(f'<div style="color: #6a737d;">{escaped}</div>')
|
|
||||||
else:
|
|
||||||
html_lines.append(f'<div>{escaped}</div>')
|
|
||||||
html_lines.append('</div>')
|
|
||||||
return '\n'.join(html_lines)
|
|
||||||
|
|
||||||
def parse_logs():
|
|
||||||
logs = []
|
|
||||||
if not os.path.exists(LOG_FILE):
|
|
||||||
return []
|
|
||||||
lines = []
|
|
||||||
with open(LOG_FILE, "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
if line.strip():
|
|
||||||
if line.strip() not in lines:
|
|
||||||
lines.append(line.strip())
|
|
||||||
logs.append(json.loads(line.strip()))
|
|
||||||
return logs
|
|
||||||
|
|
||||||
def group_by_date(logs):
|
|
||||||
grouped = {}
|
|
||||||
for entry in logs:
|
|
||||||
date = entry["date"]
|
|
||||||
grouped.setdefault(date, []).append(entry)
|
|
||||||
return dict(sorted(grouped.items(), reverse=True))
|
|
||||||
|
|
||||||
def get_git_diff(commit_hash):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "-C", REPO_ROOT, "show", commit_hash, "--no-color"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return result.stdout
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
return f"Error retrieving diff: {e.stderr}"
|
|
||||||
|
|
||||||
def list_directory(path, base_url="/browse?path="):
|
|
||||||
try:
|
|
||||||
entries = os.listdir(path)
|
|
||||||
except OSError:
|
|
||||||
return "<div>Cannot access directory.</div>"
|
|
||||||
|
|
||||||
entries.sort()
|
|
||||||
content = "<ul>"
|
|
||||||
# Parent directory link
|
|
||||||
parent = os.path.dirname(path)
|
|
||||||
if os.path.abspath(path) != REPO_ROOT:
|
|
||||||
parent_rel = os.path.relpath(parent, REPO_ROOT)
|
|
||||||
content += f"<li><a href='{base_url}{html.escape(parent_rel)}'>.. (parent directory)</a></li>"
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
full_path = os.path.join(path, entry)
|
|
||||||
rel_path = os.path.relpath(full_path, REPO_ROOT)
|
|
||||||
if os.path.isdir(full_path):
|
|
||||||
content += f"<li>📁 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}/</a></li>"
|
|
||||||
else:
|
|
||||||
content += f"<li>📄 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}</a></li>"
|
|
||||||
content += "</ul>"
|
|
||||||
return content
|
|
||||||
|
|
||||||
def read_file_content(path):
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return f.read()
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error reading file: {e}"
|
|
||||||
|
|
||||||
def get_language_class(filename):
|
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
|
||||||
return {
|
|
||||||
'.py': 'python',
|
|
||||||
'.js': 'javascript',
|
|
||||||
'.html': 'html',
|
|
||||||
'.css': 'css',
|
|
||||||
'.json': 'json',
|
|
||||||
'.sh': 'bash',
|
|
||||||
'.md': 'markdown',
|
|
||||||
'.c': 'c',
|
|
||||||
'.cpp': 'cpp',
|
|
||||||
'.h': 'cpp',
|
|
||||||
'.java': 'java',
|
|
||||||
'.rb': 'ruby',
|
|
||||||
'.go': 'go',
|
|
||||||
'.php': 'php',
|
|
||||||
'.rs': 'rust',
|
|
||||||
'.ts': 'typescript',
|
|
||||||
'.xml': 'xml',
|
|
||||||
'.yml': 'yaml',
|
|
||||||
'.yaml': 'yaml',
|
|
||||||
}.get(ext, '')
|
|
||||||
|
|
||||||
class GitLogHandler(http.server.SimpleHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/":
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "text/html")
|
|
||||||
self.end_headers()
|
|
||||||
logs = parse_logs()
|
|
||||||
grouped = group_by_date(logs)
|
|
||||||
content = "<p><a href='/browse'>Browse Files</a></p>"
|
|
||||||
for date, commits in grouped.items():
|
|
||||||
content += f"<div class='date-header'>{date}</div>"
|
|
||||||
for c in commits:
|
|
||||||
commit_link = f"/diff?hash={c['commit']}"
|
|
||||||
content += f"""
|
|
||||||
<div class='commit'>
|
|
||||||
<div><strong>{c['line'].splitlines()[0]}</strong></div>
|
|
||||||
<div class='hash'><a href='{commit_link}'>{c['commit']}</a></div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
|
|
||||||
elif parsed.path == "/diff":
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
commit = qs.get("hash", [""])[0]
|
|
||||||
diff = format_diff_to_html(get_git_diff(html.escape(commit)))
|
|
||||||
diff_html = f"<h2>Commit: {commit}</h2><div class='diff'>{diff}</div><p><a href='/'>← Back to commits</a></p>"
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "text/html")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(HTML_TEMPLATE(diff_html).encode("utf-8"))
|
|
||||||
elif parsed.path == "/browse":
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
rel_path = qs.get("path", [""])[0]
|
|
||||||
abs_path = os.path.abspath(os.path.join(REPO_ROOT, rel_path))
|
|
||||||
# Security: prevent escaping the repo root
|
|
||||||
if not abs_path.startswith(REPO_ROOT):
|
|
||||||
self.send_error(403, "Forbidden")
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.isdir(abs_path):
|
|
||||||
content = f"<h2>Browsing: /{html.escape(rel_path)}</h2>"
|
|
||||||
content += list_directory(abs_path)
|
|
||||||
content += "<p><a href='/'>← Back to commits</a></p>"
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "text/html")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
|
|
||||||
elif os.path.isfile(abs_path):
|
|
||||||
file_content = read_file_content(abs_path)
|
|
||||||
lang_class = get_language_class(abs_path)
|
|
||||||
content = f"<h2>File: /{html.escape(rel_path)}</h2>"
|
|
||||||
content += (
|
|
||||||
f"<pre style='background:#f1f1f1; padding:1em; border-radius:4px; overflow-x:auto;'>"
|
|
||||||
f"<code class='{lang_class}'>{html.escape(file_content)}</code></pre>"
|
|
||||||
)
|
|
||||||
content += "<p><a href='{}'>← Back to directory</a></p>".format(
|
|
||||||
f"/browse?path={html.escape(os.path.dirname(rel_path))}"
|
|
||||||
)
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-type", "text/html")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
|
|
||||||
else:
|
|
||||||
self.send_error(404, "Not found")
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
with socketserver.TCPServer(("", PORT), GitLogHandler) as httpd:
|
|
||||||
print(f"Serving at http://localhost:{PORT}")
|
|
||||||
httpd.serve_forever()
|
|
||||||
break
|
|
||||||
except Exception as ex:
|
|
||||||
print(ex)
|
|
||||||
PORT += 1
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ class ChannelMessageModel(BaseModel):
|
|||||||
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
user_uid = ModelField(name="user_uid", required=True, kind=str)
|
||||||
message = ModelField(name="message", required=True, kind=str)
|
message = ModelField(name="message", required=True, kind=str)
|
||||||
html = ModelField(name="html", required=False, kind=str)
|
html = ModelField(name="html", required=False, kind=str)
|
||||||
|
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
|
||||||
|
|
||||||
def get_seconds_since_last_update(self):
|
def get_seconds_since_last_update(self):
|
||||||
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
|
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ class ChannelService(BaseService):
|
|||||||
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
|
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
async def get_recent_users(self, channel_uid):
|
||||||
|
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
|
||||||
|
yield user
|
||||||
|
|
||||||
async def get_users(self, channel_uid):
|
async def get_users(self, channel_uid):
|
||||||
async for channel_member in self.services.channel_member.find(
|
async for channel_member in self.services.channel_member.find(
|
||||||
channel_uid=channel_uid,
|
channel_uid=channel_uid,
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ from snek.system.service import BaseService
|
|||||||
class ChannelMessageService(BaseService):
|
class ChannelMessageService(BaseService):
|
||||||
mapper_name = "channel_message"
|
mapper_name = "channel_message"
|
||||||
|
|
||||||
async def create(self, channel_uid, user_uid, message):
|
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
|
|
||||||
model["channel_uid"] = channel_uid
|
model["channel_uid"] = channel_uid
|
||||||
model["user_uid"] = user_uid
|
model["user_uid"] = user_uid
|
||||||
model["message"] = message
|
model["message"] = message
|
||||||
|
model['is_final'] = is_final
|
||||||
|
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ class ChannelMessageService(BaseService):
|
|||||||
|
|
||||||
async def save(self, model):
|
async def save(self, model):
|
||||||
context = {}
|
context = {}
|
||||||
content.update(model.record)
|
context.update(model.record)
|
||||||
user = await self.app.services.user.get(model['user_uid'])
|
user = await self.app.services.user.get(model['user_uid'])
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,38 @@ from snek.system.service import BaseService
|
|||||||
|
|
||||||
class ChatService(BaseService):
|
class ChatService(BaseService):
|
||||||
|
|
||||||
async def send(self, user_uid, channel_uid, message):
|
async def finalize(self, message_uid):
|
||||||
|
channel_message = await self.services.channel_message.get(uid=message_uid)
|
||||||
|
channel_message["is_final"] = True
|
||||||
|
await self.services.channel_message.save(channel_message)
|
||||||
|
user = await self.services.user.get(uid=channel_message["user_uid"])
|
||||||
|
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
|
||||||
|
channel["last_message_on"] = now()
|
||||||
|
await self.services.channel.save(channel)
|
||||||
|
await self.services.socket.broadcast(
|
||||||
|
channel['uid'],
|
||||||
|
{
|
||||||
|
"message": channel_message["message"],
|
||||||
|
"html": channel_message["html"],
|
||||||
|
"user_uid": user['uid'],
|
||||||
|
"color": user["color"],
|
||||||
|
"channel_uid": channel['uid'],
|
||||||
|
"created_at": channel_message["created_at"],
|
||||||
|
"updated_at": channel_message["updated_at"],
|
||||||
|
"username": user["username"],
|
||||||
|
"uid": channel_message["uid"],
|
||||||
|
"user_nick": user["nick"],
|
||||||
|
"is_final": channel_message["is_final"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send(self, user_uid, channel_uid, message, is_final=True):
|
||||||
channel = await self.services.channel.get(uid=channel_uid)
|
channel = await self.services.channel.get(uid=channel_uid)
|
||||||
if not channel:
|
if not channel:
|
||||||
raise Exception("Channel not found.")
|
raise Exception("Channel not found.")
|
||||||
channel_message = await self.services.channel_message.create(
|
channel_message = await self.services.channel_message.create(
|
||||||
channel_uid, user_uid, message
|
channel_uid, user_uid, message,is_final
|
||||||
)
|
)
|
||||||
channel_message_uid = channel_message["uid"]
|
channel_message_uid = channel_message["uid"]
|
||||||
|
|
||||||
@@ -30,6 +56,7 @@ class ChatService(BaseService):
|
|||||||
"username": user["username"],
|
"username": user["username"],
|
||||||
"uid": channel_message["uid"],
|
"uid": channel_message["uid"],
|
||||||
"user_nick": user["nick"],
|
"user_nick": user["nick"],
|
||||||
|
"is_final": is_final,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self.app.create_task(
|
await self.app.create_task(
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ async def start_ssh_server(app,host,port):
|
|||||||
logger.info("Starting SFTP server setup")
|
logger.info("Starting SFTP server setup")
|
||||||
|
|
||||||
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
|
||||||
host_key_path.parent.mkdir(exist_ok=True)
|
host_key_path.parent.mkdir(exist_ok=True, parents=True)
|
||||||
try:
|
try:
|
||||||
if not host_key_path.exists():
|
if not host_key_path.exists():
|
||||||
logger.info(f"Generating new host key at {host_key_path}")
|
logger.info(f"Generating new host key at {host_key_path}")
|
||||||
|
|||||||
+11
-5
@@ -160,11 +160,15 @@ export class App extends EventHandler {
|
|||||||
typeLock = null;
|
typeLock = null;
|
||||||
typeListener = null;
|
typeListener = null;
|
||||||
typeEventChannelUid = null;
|
typeEventChannelUid = null;
|
||||||
async set_typing(channel_uid) {
|
_debug = false
|
||||||
|
async set_typing(channel_uid) {
|
||||||
this.typeEventChannel_uid = channel_uid;
|
this.typeEventChannel_uid = channel_uid;
|
||||||
}
|
}
|
||||||
|
debug() {
|
||||||
async ping(...args) {
|
this._debug = !this._debug;
|
||||||
|
this.ws._debug = this._debug;
|
||||||
|
}
|
||||||
|
async ping(...args) {
|
||||||
if (this.is_pinging) return false;
|
if (this.is_pinging) return false;
|
||||||
this.is_pinging = true;
|
this.is_pinging = true;
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
@@ -202,8 +206,10 @@ export class App extends EventHandler {
|
|||||||
this.ws.addEventListener("channel-message", (data) => {
|
this.ws.addEventListener("channel-message", (data) => {
|
||||||
me.emit("channel-message", data);
|
me.emit("channel-message", data);
|
||||||
});
|
});
|
||||||
this.ws.addEventListener("event", (data) => {
|
this.ws.addEventListener("data", (data) => {
|
||||||
console.info("aaaa");
|
if(this._debug){
|
||||||
|
console.debug(data)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.rpc.getUser(null).then((user) => {
|
this.rpc.getUser(null).then((user) => {
|
||||||
me.user = user;
|
me.user = user;
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background-color: #000000;
|
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -102,13 +101,11 @@ h2 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #000000;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background-color: #000000;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +136,6 @@ footer {
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: #000000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
@@ -244,15 +240,14 @@ footer {
|
|||||||
|
|
||||||
.chat-input {
|
.chat-input {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
background-color: #000000;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"], .chat-input textarea {
|
input[type="text"], .chat-input textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: #000000;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -357,7 +352,6 @@ a {
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
background-color: #000000;
|
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
@@ -550,3 +544,44 @@ dialog .dialog-button.secondary:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.embed-url-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-url-link img,
|
||||||
|
.embed-url-link video,
|
||||||
|
.embed-url-link iframe,
|
||||||
|
.embed-url-link div {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-url-link .page-site {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-url-link .page-name {
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #f05a28;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-url-link .page-description {
|
||||||
|
font-size: 1em;
|
||||||
|
color: #e6e6e6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-url-link .page-link {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #f05a28;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
@@ -4,13 +4,23 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
autoCompletions = {
|
autoCompletions = {
|
||||||
"example 1": () => {},
|
"example 1": () => {},
|
||||||
"example 2": () => {},
|
"example 2": () => {},
|
||||||
};
|
}
|
||||||
|
hiddenCompletions = {
|
||||||
|
"/starsRender": () => {
|
||||||
|
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = []
|
||||||
|
textarea = null
|
||||||
|
_value = ""
|
||||||
|
lastUpdateEvent = null
|
||||||
|
previousValue = ""
|
||||||
|
lastChange = null
|
||||||
|
changed = false
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.lastUpdateEvent = new Date();
|
this.lastUpdateEvent = new Date();
|
||||||
this.textarea = document.createElement("textarea");
|
this.textarea = document.createElement("textarea");
|
||||||
this._value = "";
|
|
||||||
this.value = this.getAttribute("value") || "";
|
this.value = this.getAttribute("value") || "";
|
||||||
this.previousValue = this.value;
|
this.previousValue = this.value;
|
||||||
this.lastChange = new Date();
|
this.lastChange = new Date();
|
||||||
@@ -25,12 +35,15 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
this._value = value || "";
|
this._value = value || "";
|
||||||
this.textarea.value = this._value;
|
this.textarea.value = this._value;
|
||||||
}
|
}
|
||||||
|
get allAutoCompletions() {
|
||||||
|
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
|
||||||
|
}
|
||||||
resolveAutoComplete() {
|
resolveAutoComplete() {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let value = null;
|
let value = null;
|
||||||
Object.keys(this.autoCompletions).forEach((key) => {
|
|
||||||
if (key.startsWith(this.value)) {
|
Object.keys(this.allAutoCompletions).forEach((key) => {
|
||||||
|
if (key.startsWith(this.value.split(" ")[0])) {
|
||||||
count++;
|
count++;
|
||||||
value = key;
|
value = key;
|
||||||
}
|
}
|
||||||
@@ -56,7 +69,7 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
|
|
||||||
}
|
}
|
||||||
extractMentions(text) {
|
extractMentions(text) {
|
||||||
const regex = /@([a-zA-Z0-9_]+)/g;
|
const regex = /@([a-zA-Z0-9_-]+)/g;
|
||||||
const mentions = [];
|
const mentions = [];
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
@@ -70,13 +83,23 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
return mentions.map(mention => {
|
return mentions.map(mention => {
|
||||||
let closestAuthor = null;
|
let closestAuthor = null;
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
|
const lowerMention = mention.toLowerCase();
|
||||||
|
|
||||||
authors.forEach(author => {
|
authors.forEach(author => {
|
||||||
const distance = this.levenshteinDistance(mention.toLowerCase(), author.toLowerCase());
|
const lowerAuthor = author.toLowerCase();
|
||||||
|
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||||
|
|
||||||
|
|
||||||
|
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
|
||||||
|
distance += 10
|
||||||
|
}
|
||||||
|
|
||||||
if (distance < minDistance) {
|
if (distance < minDistance) {
|
||||||
minDistance = distance;
|
minDistance = distance;
|
||||||
closestAuthor = author;
|
closestAuthor = author;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { mention, closestAuthor, distance: minDistance };
|
return { mention, closestAuthor, distance: minDistance };
|
||||||
@@ -118,11 +141,9 @@ levenshteinDistance(a, b) {
|
|||||||
const mentions = this.extractMentions(text);
|
const mentions = this.extractMentions(text);
|
||||||
|
|
||||||
const matches = this.matchMentionsToAuthors(mentions, authors);
|
const matches = this.matchMentionsToAuthors(mentions, authors);
|
||||||
console.info(matches)
|
|
||||||
let updatedText = text;
|
let updatedText = text;
|
||||||
matches.forEach(({ mention, closestAuthor }) => {
|
matches.forEach(({ mention, closestAuthor }) => {
|
||||||
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
const mentionRegex = new RegExp(`@${mention}`, 'g');
|
||||||
console.info(closestAuthor)
|
|
||||||
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,10 +162,12 @@ levenshteinDistance(a, b) {
|
|||||||
const me = this;
|
const me = this;
|
||||||
this.liveType = this.getAttribute("live-type") === "true";
|
this.liveType = this.getAttribute("live-type") === "true";
|
||||||
this.liveTypeInterval =
|
this.liveTypeInterval =
|
||||||
parseInt(this.getAttribute("live-type-interval")) || 3;
|
parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||||
this.channelUid = this.getAttribute("channel");
|
this.channelUid = this.getAttribute("channel");
|
||||||
|
|
||||||
this.users = await app.rpc.getUsers(this.channelUid)
|
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
||||||
|
this.users = users
|
||||||
|
})
|
||||||
this.messageUid = null;
|
this.messageUid = null;
|
||||||
|
|
||||||
this.classList.add("chat-input");
|
this.classList.add("chat-input");
|
||||||
@@ -178,9 +201,10 @@ levenshteinDistance(a, b) {
|
|||||||
|
|
||||||
this.textarea.addEventListener("keydown", (e) => {
|
this.textarea.addEventListener("keydown", (e) => {
|
||||||
this.value = e.target.value;
|
this.value = e.target.value;
|
||||||
|
let autoCompletion = null;
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let autoCompletion = this.resolveAutoComplete();
|
autoCompletion = this.resolveAutoComplete();
|
||||||
if (autoCompletion) {
|
if (autoCompletion) {
|
||||||
e.target.value = autoCompletion;
|
e.target.value = autoCompletion;
|
||||||
this.value = autoCompletion;
|
this.value = autoCompletion;
|
||||||
@@ -196,18 +220,18 @@ levenshteinDistance(a, b) {
|
|||||||
if (!message) {
|
if (!message) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
|
||||||
let autoCompletion = this.autoCompletions[message];
|
if (autoCompletionHandler) {
|
||||||
if (autoCompletion) {
|
autoCompletionHandler();
|
||||||
this.value = "";
|
this.value = "";
|
||||||
this.previousValue = "";
|
this.previousValue = "";
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
autoCompletion();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateMessage()
|
this.updateMessage()
|
||||||
|
app.rpc.finalizeMessage(this.messageUid)
|
||||||
this.value = "";
|
this.value = "";
|
||||||
this.previousValue = "";
|
this.previousValue = "";
|
||||||
this.messageUid = null;
|
this.messageUid = null;
|
||||||
@@ -239,7 +263,7 @@ levenshteinDistance(a, b) {
|
|||||||
e.detail.files.forEach((file) => {
|
e.detail.files.forEach((file) => {
|
||||||
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||||
});
|
});
|
||||||
app.rpc.sendMessage(this.channelUid, message);
|
app.rpc.sendMessage(this.channelUid, message,true);
|
||||||
});
|
});
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -250,13 +274,25 @@ levenshteinDistance(a, b) {
|
|||||||
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
||||||
return millisecondsDifference / 1000;
|
return millisecondsDifference / 1000;
|
||||||
}
|
}
|
||||||
|
isSubsequence(s, t) {
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < s.length && j < t.length) {
|
||||||
|
if (s[i] === t[j]) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return i === s.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
newMessage() {
|
newMessage() {
|
||||||
if (!this.messageUid) {
|
if (!this.messageUid) {
|
||||||
this.messageUid = "?";
|
this.messageUid = "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(this.channelUid, this.value).then((uid) => {
|
this.value = this.replaceMentionsWithAuthors(this.value);
|
||||||
|
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
|
||||||
if (this.liveType) {
|
if (this.liveType) {
|
||||||
this.messageUid = uid;
|
this.messageUid = uid;
|
||||||
}
|
}
|
||||||
@@ -326,11 +362,11 @@ levenshteinDistance(a, b) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(channelUid, value) {
|
async sendMessage(channelUid, value,is_final) {
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await app.rpc.sendMessage(channelUid, value);
|
return await app.rpc.sendMessage(channelUid, value,is_final);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,19 @@ class MessageList extends HTMLElement {
|
|||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
isElementVisible(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top >= 0 &&
|
||||||
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isScrolledToBottom() {
|
||||||
|
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
||||||
|
}
|
||||||
scrollToBottom(force) {
|
scrollToBottom(force) {
|
||||||
console.info("Scrolling down")
|
|
||||||
// if (force) {
|
|
||||||
this.scrollTop = this.scrollHeight;
|
this.scrollTop = this.scrollHeight;
|
||||||
|
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
@@ -49,7 +59,6 @@ class MessageList extends HTMLElement {
|
|||||||
this.scrollTop = this.scrollHeight;
|
this.scrollTop = this.scrollHeight;
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
},200)
|
},200)
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
updateMessageText(uid, message) {
|
updateMessageText(uid, message) {
|
||||||
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
||||||
@@ -57,12 +66,15 @@ class MessageList extends HTMLElement {
|
|||||||
if (!messageDiv) {
|
if (!messageDiv) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const scrollToBottom = this.isScrolledToBottom();
|
||||||
const receivedHtml = document.createElement("div");
|
const receivedHtml = document.createElement("div");
|
||||||
receivedHtml.innerHTML = message.html;
|
receivedHtml.innerHTML = message.html;
|
||||||
const html = receivedHtml.querySelector(".text").innerHTML;
|
const html = receivedHtml.querySelector(".text").innerHTML;
|
||||||
const textElement = messageDiv.querySelector(".text");
|
const textElement = messageDiv.querySelector(".text");
|
||||||
textElement.innerHTML = html;
|
textElement.innerHTML = html;
|
||||||
textElement.style.display = message.text == "" ? "none" : "block";
|
textElement.style.display = message.text == "" ? "none" : "block";
|
||||||
|
if(scrollToBottom)
|
||||||
|
this.scrollToBottom(true)
|
||||||
}
|
}
|
||||||
triggerGlow(uid,color) {
|
triggerGlow(uid,color) {
|
||||||
app.starField.glowColor(color)
|
app.starField.glowColor(color)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--star-color);
|
background-color: var(--star-color);
|
||||||
animation: twinkle 2s infinite ease-in-out;
|
animation: twinkle 2s infinite ease-in-out;
|
||||||
}
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes twinkle {
|
@keyframes twinkle {
|
||||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export class Socket extends EventHandler {
|
|||||||
|
|
||||||
shouldReconnect = true;
|
shouldReconnect = true;
|
||||||
|
|
||||||
|
_debug = false;
|
||||||
|
|
||||||
get isConnected() {
|
get isConnected() {
|
||||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||||
}
|
}
|
||||||
@@ -112,7 +114,12 @@ export class Socket extends EventHandler {
|
|||||||
get(_, prop) {
|
get(_, prop) {
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
const functionName = me._camelToSnake(prop);
|
const functionName = me._camelToSnake(prop);
|
||||||
return me.call(functionName, ...args);
|
if(me._debug){
|
||||||
|
const call = {}
|
||||||
|
call[functionName] = args
|
||||||
|
console.debug(call)
|
||||||
|
}
|
||||||
|
return me.call(functionName, ...args);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
DEFAULT_LIMIT = 30
|
DEFAULT_LIMIT = 30
|
||||||
import typing
|
import typing
|
||||||
|
import asyncio
|
||||||
from snek.system.model import BaseModel
|
from snek.system.model import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -12,13 +12,20 @@ class BaseMapper:
|
|||||||
|
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
self.default_limit = self.__class__.default_limit
|
self.default_limit = self.__class__.default_limit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
return self.app.db
|
return self.app.db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loop(self):
|
||||||
|
return asyncio.get_event_loop()
|
||||||
|
|
||||||
|
async def run_in_executor(self, func, *args, **kwargs):
|
||||||
|
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
async def new(self):
|
async def new(self):
|
||||||
return self.model_class(mapper=self, app=self.app)
|
return self.model_class(mapper=self, app=self.app)
|
||||||
|
|
||||||
@@ -29,7 +36,8 @@ class BaseMapper:
|
|||||||
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
async def get(self, uid: str = None, **kwargs) -> BaseModel:
|
||||||
if uid:
|
if uid:
|
||||||
kwargs["uid"] = uid
|
kwargs["uid"] = uid
|
||||||
record = self.table.find_one(**kwargs)
|
|
||||||
|
record = await self.run_in_executor(self.table.find_one,**kwargs)
|
||||||
if not record:
|
if not record:
|
||||||
return None
|
return None
|
||||||
record = dict(record)
|
record = dict(record)
|
||||||
@@ -40,31 +48,31 @@ class BaseMapper:
|
|||||||
return await self.model_class.from_record(mapper=self, record=record)
|
return await self.model_class.from_record(mapper=self, record=record)
|
||||||
|
|
||||||
async def exists(self, **kwargs):
|
async def exists(self, **kwargs):
|
||||||
return self.table.exists(**kwargs)
|
return await self.run_in_executor(self.table.exists,**kwargs)
|
||||||
|
|
||||||
async def count(self, **kwargs) -> int:
|
async def count(self, **kwargs) -> int:
|
||||||
return self.table.count(**kwargs)
|
return await self.run_in_executor(self.table.count, **kwargs)
|
||||||
|
|
||||||
async def save(self, model: BaseModel) -> bool:
|
async def save(self, model: BaseModel) -> bool:
|
||||||
if not model.record.get("uid"):
|
if not model.record.get("uid"):
|
||||||
raise Exception(f"Attempt to save without uid: {model.record}.")
|
raise Exception(f"Attempt to save without uid: {model.record}.")
|
||||||
model.updated_at.update()
|
model.updated_at.update()
|
||||||
return self.table.upsert(model.record, ["uid"])
|
return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
|
||||||
|
|
||||||
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
async def find(self, **kwargs) -> typing.AsyncGenerator:
|
||||||
if not kwargs.get("_limit"):
|
if not kwargs.get("_limit"):
|
||||||
kwargs["_limit"] = self.default_limit
|
kwargs["_limit"] = self.default_limit
|
||||||
for record in self.table.find(**kwargs):
|
for record in await self.run_in_executor(self.table.find, **kwargs):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
for key, value in record.items():
|
for key, value in record.items():
|
||||||
model[key] = value
|
model[key] = value
|
||||||
yield model
|
yield model
|
||||||
|
|
||||||
async def query(self, sql, *args):
|
async def query(self, sql, *args):
|
||||||
for record in self.db.query(sql, *args):
|
for record in await self.run_in_executor(self.db.query,sql, *args):
|
||||||
yield dict(record)
|
yield dict(record)
|
||||||
|
|
||||||
async def delete(self, **kwargs) -> int:
|
async def delete(self, **kwargs) -> int:
|
||||||
if not kwargs or not isinstance(kwargs, dict):
|
if not kwargs or not isinstance(kwargs, dict):
|
||||||
raise Exception("Can't execute delete with no filter.")
|
raise Exception("Can't execute delete with no filter.")
|
||||||
return self.table.delete(**kwargs)
|
return await self.run_in_executor(self.table.delete, **kwargs)
|
||||||
|
|||||||
+156
-4
@@ -1,9 +1,11 @@
|
|||||||
import re
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from app.cache import time_cache
|
||||||
import emoji
|
import emoji
|
||||||
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from jinja2 import TemplateSyntaxError, nodes
|
from jinja2 import TemplateSyntaxError, nodes
|
||||||
from jinja2.ext import Extension
|
from jinja2.ext import Extension
|
||||||
@@ -231,6 +233,153 @@ def linkify_https(text):
|
|||||||
return set_link_target_blank(str(soup))
|
return set_link_target_blank(str(soup))
|
||||||
|
|
||||||
|
|
||||||
|
@time_cache(timeout=60*60)
|
||||||
|
def get_url_content(url):
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def embed_url(text):
|
||||||
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
|
|
||||||
|
attachments = {}
|
||||||
|
|
||||||
|
for element in soup.find_all("a"):
|
||||||
|
if "href" in element.attrs and element.attrs["href"].startswith("http"):
|
||||||
|
page_url = urlparse(element.attrs["href"])
|
||||||
|
page = get_url_content(element.attrs["href"])
|
||||||
|
if page:
|
||||||
|
parsed_page = BeautifulSoup(page, "html.parser")
|
||||||
|
head_info = parsed_page.find("head")
|
||||||
|
if head_info:
|
||||||
|
|
||||||
|
def get_element_options(
|
||||||
|
elem=None, meta=None, ograph=None, twitter=None
|
||||||
|
):
|
||||||
|
if twitter:
|
||||||
|
tw_tag = head_info.find(
|
||||||
|
"meta", attrs={"name": "twitter:" + twitter}
|
||||||
|
) or head_info.find(
|
||||||
|
"meta", attrs={"property": "twitter:" + twitter}
|
||||||
|
)
|
||||||
|
if tw_tag:
|
||||||
|
return tw_tag.get("content", tw_tag.get("value", None))
|
||||||
|
|
||||||
|
if ograph:
|
||||||
|
og_tag = head_info.find(
|
||||||
|
"meta", attrs={"property": "og:" + ograph}
|
||||||
|
) or head_info.find("meta", attrs={"name": "og:" + ograph})
|
||||||
|
if og_tag:
|
||||||
|
return og_tag.get("content", og_tag.get("value", None))
|
||||||
|
|
||||||
|
if meta:
|
||||||
|
meta_tag = head_info.find(
|
||||||
|
"meta", attrs={"name": meta}
|
||||||
|
) or head_info.find("meta", attrs={"property": meta})
|
||||||
|
if meta_tag:
|
||||||
|
return meta_tag.get(
|
||||||
|
"content", meta_tag.get("value", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
if elem:
|
||||||
|
elem_tag = head_info.find(elem)
|
||||||
|
if elem_tag:
|
||||||
|
return elem_tag.text
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
original_link_name = element.attrs["href"]
|
||||||
|
|
||||||
|
if original_link_name in attachments:
|
||||||
|
continue
|
||||||
|
|
||||||
|
page_name = (
|
||||||
|
get_element_options("title", "title", "title", "title")
|
||||||
|
or page_url.netloc
|
||||||
|
)
|
||||||
|
page_site = (
|
||||||
|
get_element_options(None, "site", "site", "site")
|
||||||
|
or page_url.netloc
|
||||||
|
)
|
||||||
|
page_description = get_element_options(
|
||||||
|
None, "description", "description", "description"
|
||||||
|
)
|
||||||
|
page_image = get_element_options(None, "image", "image", "image")
|
||||||
|
page_image_alt = get_element_options(
|
||||||
|
None, "image:alt", "image:alt", "image:alt"
|
||||||
|
)
|
||||||
|
page_video = get_element_options(None, "video", "video", "video")
|
||||||
|
page_audio = get_element_options(None, "audio", "audio", "audio")
|
||||||
|
|
||||||
|
preview_size = (
|
||||||
|
get_element_options(None, None, None, "card")
|
||||||
|
or "summary_large_image"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
attachment_base = BeautifulSoup(str(element), "html.parser")
|
||||||
|
attachments[original_link_name] = attachment_base
|
||||||
|
|
||||||
|
attachment = next(attachment_base.children)
|
||||||
|
|
||||||
|
attachment.clear()
|
||||||
|
attachment.attrs["class"] = "embed-url-link"
|
||||||
|
|
||||||
|
render_element = attachment
|
||||||
|
|
||||||
|
if page_image:
|
||||||
|
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
|
||||||
|
render_element.append(
|
||||||
|
BeautifulSoup(image_template, "html.parser")
|
||||||
|
)
|
||||||
|
if page_video:
|
||||||
|
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
|
||||||
|
render_element.append(
|
||||||
|
BeautifulSoup(video_template, "html.parser")
|
||||||
|
)
|
||||||
|
if page_audio:
|
||||||
|
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
|
||||||
|
render_element.append(
|
||||||
|
BeautifulSoup(audio_template, "html.parser")
|
||||||
|
)
|
||||||
|
|
||||||
|
description_element_base = BeautifulSoup(
|
||||||
|
"<span class='description'></span>", "html.parser"
|
||||||
|
)
|
||||||
|
description_element = next(description_element_base.children)
|
||||||
|
description_element.append(
|
||||||
|
BeautifulSoup(
|
||||||
|
f'<p class="page-site">{page_site}</p>',
|
||||||
|
"html.parser",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
description_element.append(
|
||||||
|
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
|
||||||
|
)
|
||||||
|
|
||||||
|
description_element.append(
|
||||||
|
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
|
||||||
|
)
|
||||||
|
|
||||||
|
description_element.append(
|
||||||
|
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
|
||||||
|
)
|
||||||
|
|
||||||
|
render_element.append(description_element_base)
|
||||||
|
|
||||||
|
|
||||||
|
for attachment in attachments.values():
|
||||||
|
soup.append(attachment)
|
||||||
|
|
||||||
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
class EmojiExtension(Extension):
|
class EmojiExtension(Extension):
|
||||||
tags = {"emoji"}
|
tags = {"emoji"}
|
||||||
|
|
||||||
@@ -276,6 +425,9 @@ class LinkifyExtension(Extension):
|
|||||||
result = embed_youtube(result)
|
result = embed_youtube(result)
|
||||||
|
|
||||||
result = enrich_image_rendering(result)
|
result = enrich_image_rendering(result)
|
||||||
|
|
||||||
|
result = embed_url(result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-15
@@ -13,7 +13,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="message-list-bottom"></div>
|
<div class="message-list-bottom"></div>
|
||||||
</message-list>
|
</message-list>
|
||||||
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
|
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
||||||
</section>
|
</section>
|
||||||
{% include "dialog_help.html" %}
|
{% include "dialog_help.html" %}
|
||||||
{% include "dialog_online.html" %}
|
{% include "dialog_online.html" %}
|
||||||
@@ -157,6 +157,10 @@ app.ws.addEventListener("refresh", (data) => {
|
|||||||
app.starField.showNotify(data.message);
|
app.starField.showNotify(data.message);
|
||||||
setTimeout(() => window.location.reload(), 4000);
|
setTimeout(() => window.location.reload(), 4000);
|
||||||
});
|
});
|
||||||
|
app.ws.addEventListener("stars_render", (data)=>{
|
||||||
|
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
|
||||||
|
setTimeout(() => app.starField.shuffleAll(5000), 10000);
|
||||||
|
})
|
||||||
app.ws.addEventListener("deployed", (data) => {
|
app.ws.addEventListener("deployed", (data) => {
|
||||||
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
|
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
|
||||||
setTimeout(() => app.starField.shuffleAll(5000), 10000);
|
setTimeout(() => app.starField.shuffleAll(5000), 10000);
|
||||||
@@ -167,7 +171,9 @@ app.ws.addEventListener("starfield.render_word", (data) => {
|
|||||||
|
|
||||||
// --- Channel message event ---
|
// --- Channel message event ---
|
||||||
app.addEventListener("channel-message", (data) => {
|
app.addEventListener("channel-message", (data) => {
|
||||||
|
|
||||||
let display = data.text && data.text.trim() ? 'block' : 'none';
|
let display = data.text && data.text.trim() ? 'block' : 'none';
|
||||||
|
|
||||||
if (data.channel_uid !== channelUid) {
|
if (data.channel_uid !== channelUid) {
|
||||||
if (!isMentionForSomeoneElse(data.message)) {
|
if (!isMentionForSomeoneElse(data.message)) {
|
||||||
channelSidebar.notify(data);
|
channelSidebar.notify(data);
|
||||||
@@ -179,13 +185,22 @@ app.addEventListener("channel-message", (data) => {
|
|||||||
if (isMentionToMe(data.message)) {
|
if (isMentionToMe(data.message)) {
|
||||||
app.playSound("mention");
|
app.playSound("mention");
|
||||||
} else if (!isMentionForSomeoneElse(data.message)) {
|
} else if (!isMentionForSomeoneElse(data.message)) {
|
||||||
app.playSound("message");
|
if(data.is_final){
|
||||||
|
app.playSound("message");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const lastMessage = messagesContainer.querySelector(".message:last-child");
|
|
||||||
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
||||||
const doScrollDown = !lastMessage || isElementVisible(lastMessage);
|
const doScrollDown = messagesContainer.isScrolledToBottom();
|
||||||
|
|
||||||
|
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
||||||
|
if (oldMessage) {
|
||||||
|
oldMessage.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const message = document.createElement("div");
|
const message = document.createElement("div");
|
||||||
|
|
||||||
|
|
||||||
message.innerHTML = data.html;
|
message.innerHTML = data.html;
|
||||||
message.style.display = display;
|
message.style.display = display;
|
||||||
messagesContainer.insertBefore(message.firstChild, lastElement);
|
messagesContainer.insertBefore(message.firstChild, lastElement);
|
||||||
@@ -211,6 +226,18 @@ document.addEventListener('keydown', function(event) {
|
|||||||
loadExtra();
|
loadExtra();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if(event.key == 'i' && !chatInputField.isActive()) {
|
||||||
|
event.preventDefault();
|
||||||
|
chatInputField.focus();
|
||||||
|
}
|
||||||
|
if(event.key == 'r' && !chatInputField.isActive()) {
|
||||||
|
event.preventDefault();
|
||||||
|
const replyLinks = document.querySelectorAll('.time a')
|
||||||
|
if(replyLinks.length){
|
||||||
|
const replyLink = replyLinks[replyLinks.length - 1]
|
||||||
|
replyLink.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.shiftKey && event.key === 'G') {
|
if (event.shiftKey && event.key === 'G') {
|
||||||
if (!chatInputField.isActive()) {
|
if (!chatInputField.isActive()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -222,7 +249,6 @@ document.addEventListener('keydown', function(event) {
|
|||||||
|
|
||||||
// --- Image click-to-zoom (delegated) ---
|
// --- Image click-to-zoom (delegated) ---
|
||||||
// --- Layout update ---
|
// --- Layout update ---
|
||||||
let lastMessage;
|
|
||||||
function updateLayout(doScrollDown) {
|
function updateLayout(doScrollDown) {
|
||||||
updateTimes();
|
updateTimes();
|
||||||
let previousUser = null, previousDate = null;
|
let previousUser = null, previousDate = null;
|
||||||
@@ -246,20 +272,11 @@ function updateLayout(doScrollDown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
lastMessage = messagesContainer.querySelector(".message:last-child");
|
|
||||||
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Utility: check if element is visible ---
|
// --- Utility: check if element is visible ---
|
||||||
function isElementVisible(element) {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
rect.top >= 0 &&
|
|
||||||
rect.left >= 0 &&
|
|
||||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
|
||||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Utility: check if scrolled past half ---
|
// --- Utility: check if scrolled past half ---
|
||||||
function isScrolledPastHalf() {
|
function isScrolledPastHalf() {
|
||||||
|
|||||||
+41
-10
@@ -193,6 +193,21 @@ class RPCView(BaseView):
|
|||||||
)
|
)
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
|
async def finalize_message(self, message_uid):
|
||||||
|
self._require_login()
|
||||||
|
message = await self.services.channel_message.get(message_uid)
|
||||||
|
if not message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if message["user_uid"] != self.user_uid:
|
||||||
|
raise Exception("Not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
if not message['is_final']:
|
||||||
|
await self.services.chat.finalize(message['uid'])
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def update_message_text(self,message_uid, text):
|
async def update_message_text(self,message_uid, text):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
message = await self.services.channel_message.get(message_uid)
|
message = await self.services.channel_message.get(message_uid)
|
||||||
@@ -200,6 +215,7 @@ class RPCView(BaseView):
|
|||||||
raise Exception("Not allowed")
|
raise Exception("Not allowed")
|
||||||
|
|
||||||
if message.get_seconds_since_last_update() > 3:
|
if message.get_seconds_since_last_update() > 3:
|
||||||
|
await self.finalize_message(message["uid"])
|
||||||
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
|
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
|
||||||
|
|
||||||
message['message'] = text
|
message['message'] = text
|
||||||
@@ -221,9 +237,9 @@ class RPCView(BaseView):
|
|||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
async def send_message(self, channel_uid, message):
|
async def send_message(self, channel_uid, message,is_final=True):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
message = await self.services.chat.send(self.user_uid, channel_uid, message)
|
message = await self.services.chat.send(self.user_uid, channel_uid, message,is_final)
|
||||||
|
|
||||||
return message["uid"]
|
return message["uid"]
|
||||||
|
|
||||||
@@ -314,19 +330,28 @@ class RPCView(BaseView):
|
|||||||
self._require_login()
|
self._require_login()
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
record.record async for record in self.services.channel.get_online_users(channel_uid)
|
record async for record in self.services.channel.get_recent_users(channel_uid)
|
||||||
]
|
]
|
||||||
for result in results:
|
results = sorted(results, key=lambda x: x["nick"])
|
||||||
del result['email']
|
|
||||||
del result['password']
|
|
||||||
del result['deleted_at']
|
|
||||||
del result['updated_at']
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def echo(self, obj):
|
async def echo(self, obj):
|
||||||
await self.ws.send_json(obj)
|
await self.ws.send_json(obj)
|
||||||
return "noresponse"
|
return "noresponse"
|
||||||
|
|
||||||
|
async def get_recent_users(self, channel_uid):
|
||||||
|
self._require_login()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"uid": record["uid"],
|
||||||
|
"username": record["username"],
|
||||||
|
"nick": record["nick"],
|
||||||
|
"last_ping": record["last_ping"],
|
||||||
|
}
|
||||||
|
async for record in self.services.channel.get_recent_users(channel_uid)
|
||||||
|
]
|
||||||
|
|
||||||
async def get_users(self, channel_uid):
|
async def get_users(self, channel_uid):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
|
|
||||||
@@ -355,7 +380,13 @@ class RPCView(BaseView):
|
|||||||
return {"pong": args}
|
return {"pong": args}
|
||||||
|
|
||||||
|
|
||||||
|
async def stars_render(self, channel_uid, message):
|
||||||
|
|
||||||
|
for user in await self.get_online_users(channel_uid):
|
||||||
|
try:
|
||||||
|
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
|
||||||
|
except Exception as ex:
|
||||||
|
print(ex)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
scheduled = []
|
scheduled = []
|
||||||
@@ -382,7 +413,7 @@ class RPCView(BaseView):
|
|||||||
"message": "Finishing deployment"}
|
"message": "Finishing deployment"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": {
|
await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
|
||||||
"uptime": self.request.app.uptime}
|
"uptime": self.request.app.uptime}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user